airloom 0.1.28 → 0.1.30

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 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;
@@ -179,6 +180,7 @@ var Channel = class extends EventEmitter2 {
179
180
  role;
180
181
  streams = /* @__PURE__ */ new Map();
181
182
  _ready = false;
183
+ _closed = false;
182
184
  msgCounter = 0;
183
185
  constructor(opts) {
184
186
  super();
@@ -201,8 +203,12 @@ var Channel = class extends EventEmitter2 {
201
203
  this._ready = false;
202
204
  this.emit("peer_left");
203
205
  });
204
- this.adapter.onError((err) => this.emit("error", err));
205
- this.adapter.onDisconnect(() => this.emit("disconnect"));
206
+ this.adapter.onError((err) => {
207
+ if (!this._closed) this.emit("error", err);
208
+ });
209
+ this.adapter.onDisconnect(() => {
210
+ if (!this._closed) this.emit("disconnect");
211
+ });
206
212
  }
207
213
  handlePayload(base64Payload) {
208
214
  try {
@@ -296,6 +302,7 @@ var Channel = class extends EventEmitter2 {
296
302
  });
297
303
  }
298
304
  close() {
305
+ this._closed = true;
299
306
  for (const stream of this.streams.values()) stream._end();
300
307
  this.streams.clear();
301
308
  this.adapter.close();
@@ -553,9 +560,15 @@ var AblyAdapter = class {
553
560
  }
554
561
  });
555
562
  this.channel.presence.subscribe("leave", (member) => {
556
- if (member.clientId !== this.clientId) {
563
+ if (member.clientId === this.clientId) return;
564
+ this.channel.presence.get().then((members2) => {
565
+ const hasPeer2 = members2.some((m) => m.clientId !== this.clientId);
566
+ if (!hasPeer2) {
567
+ this.peerLeftHandlers.forEach((h) => h());
568
+ }
569
+ }).catch(() => {
557
570
  this.peerLeftHandlers.forEach((h) => h());
558
- }
571
+ });
559
572
  });
560
573
  await this.channel.presence.enter({ role });
561
574
  const members = await this.channel.presence.get();
@@ -566,7 +579,10 @@ var AblyAdapter = class {
566
579
  }
567
580
  send(payload) {
568
581
  if (!this.channel || !this._connected) return;
569
- this.channel.publish("forward", payload);
582
+ this.channel.publish("forward", payload).catch((err) => {
583
+ console.error("[ably] publish error:", err.message);
584
+ this.errorHandlers.forEach((h) => h(err));
585
+ });
570
586
  }
571
587
  onMessage(handler) {
572
588
  this.messageHandlers.push(handler);
@@ -584,6 +600,11 @@ var AblyAdapter = class {
584
600
  this.disconnectHandlers.push(handler);
585
601
  }
586
602
  close() {
603
+ this.messageHandlers = [];
604
+ this.peerJoinedHandlers = [];
605
+ this.peerLeftHandlers = [];
606
+ this.errorHandlers = [];
607
+ this.disconnectHandlers = [];
587
608
  this.channel?.presence.leave().catch(() => {
588
609
  });
589
610
  this.channel?.detach().catch(() => {
@@ -744,6 +765,24 @@ var OpenAIAdapter = class {
744
765
  import { spawn } from "node:child_process";
745
766
  import { existsSync } from "node:fs";
746
767
  import { delimiter, isAbsolute, join, resolve } from "node:path";
768
+
769
+ // src/log.ts
770
+ function ts() {
771
+ const d = /* @__PURE__ */ new Date();
772
+ const hh = String(d.getHours()).padStart(2, "0");
773
+ const mm = String(d.getMinutes()).padStart(2, "0");
774
+ const ss = String(d.getSeconds()).padStart(2, "0");
775
+ const ms = String(d.getMilliseconds()).padStart(3, "0");
776
+ return `${hh}:${mm}:${ss}.${ms}`;
777
+ }
778
+ function log(...args) {
779
+ console.log(ts(), ...args);
780
+ }
781
+ function logError(...args) {
782
+ console.error(ts(), ...args);
783
+ }
784
+
785
+ // src/adapters/cli.ts
747
786
  var CLI_PRESETS = [
748
787
  {
749
788
  id: "devin",
@@ -884,7 +923,7 @@ var CLIAdapter = class {
884
923
  if (this.pty) return this.pty;
885
924
  const nodePty = await import("node-pty");
886
925
  const executable = resolveExecutable(this.command) ?? this.command;
887
- console.log(`[cli-repl] Spawning PTY: ${executable} ${this.args.join(" ")}`);
926
+ log(`[cli-repl] Spawning PTY: ${executable} ${this.args.join(" ")}`);
888
927
  const pty = nodePty.spawn(executable, this.args, {
889
928
  name: "xterm-256color",
890
929
  cols: 120,
@@ -894,7 +933,7 @@ var CLIAdapter = class {
894
933
  });
895
934
  pty.onData((data) => this.onData(data));
896
935
  pty.onExit(({ exitCode }) => {
897
- console.log(`[cli-repl] PTY exited (code ${exitCode})`);
936
+ log(`[cli-repl] PTY exited (code ${exitCode})`);
898
937
  this.pty = null;
899
938
  this.ptyState = "idle";
900
939
  this.finishResponse();
@@ -1017,7 +1056,7 @@ function saveConfig(config) {
1017
1056
  mkdirSync(CONFIG_DIR, { recursive: true });
1018
1057
  writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
1019
1058
  } catch (err) {
1020
- console.error("[config] Failed to save:", err.message);
1059
+ logError("[config] Failed to save:", err.message);
1021
1060
  }
1022
1061
  }
1023
1062
  function getConfigPath() {
@@ -1038,7 +1077,7 @@ function fixSpawnHelperPermissions() {
1038
1077
  const mode = statSync(helperPath).mode;
1039
1078
  if (!(mode & 73)) {
1040
1079
  chmodSync(helperPath, mode | 493);
1041
- console.log(`[host] Fixed spawn-helper permissions: ${helperPath}`);
1080
+ log(`[host] Fixed spawn-helper permissions: ${helperPath}`);
1042
1081
  }
1043
1082
  } catch {
1044
1083
  }
@@ -1169,25 +1208,29 @@ var TerminalSession = class {
1169
1208
  cols = 120;
1170
1209
  rows = 36;
1171
1210
  outputBuffer = "";
1211
+ /** True after the first viewer has ever attached. The pre-viewer output
1212
+ * buffer contains PTY startup junk (zsh PROMPT_SP "%", resize-triggered
1213
+ * prompt redraws) that shouldn't be replayed. */
1214
+ hasHadViewer = false;
1172
1215
  start() {
1173
1216
  const command = getDefaultTerminalCommand(this.getLaunchCommand?.());
1174
1217
  const file = resolveExecutable2(command.file) ?? command.file;
1175
1218
  const cwd = process.cwd();
1176
- console.log(`[host] PTY spawn: ${file} ${command.args.join(" ")} (${this.cols}x${this.rows}) node=${process.version}`);
1177
- const env = { ...process.env, TERM: "xterm-256color" };
1178
- const spawnOpts = { name: "xterm-256color", cols: this.cols, rows: this.rows, cwd, env };
1219
+ log(`[host] PTY spawn: ${file} ${command.args.join(" ")} (${this.cols}x${this.rows}) node=${process.version}`);
1220
+ const env2 = { ...process.env, TERM: "xterm-256color" };
1221
+ const spawnOpts = { name: "xterm-256color", cols: this.cols, rows: this.rows, cwd, env: env2 };
1179
1222
  try {
1180
1223
  this.pty = spawn2(file, command.args, spawnOpts);
1181
1224
  } catch (err) {
1182
1225
  const e = err;
1183
- console.error(`[host] PTY spawn failed: ${e.message} (code=${e.code ?? "none"}) file=${file} cwd=${cwd}`);
1226
+ logError(`[host] PTY spawn failed: ${e.message} (code=${e.code ?? "none"}) file=${file} cwd=${cwd}`);
1184
1227
  if (file !== "/bin/sh") {
1185
- console.error("[host] Retrying with /bin/sh...");
1228
+ logError("[host] Retrying with /bin/sh...");
1186
1229
  try {
1187
1230
  this.pty = spawn2("/bin/sh", [], spawnOpts);
1188
- console.log("[host] PTY fallback to /bin/sh succeeded");
1231
+ log("[host] PTY fallback to /bin/sh succeeded");
1189
1232
  } catch (err2) {
1190
- console.error("[host] PTY fallback also failed:", err2.message);
1233
+ logError("[host] PTY fallback also failed:", err2.message);
1191
1234
  return;
1192
1235
  }
1193
1236
  } else {
@@ -1195,7 +1238,6 @@ var TerminalSession = class {
1195
1238
  }
1196
1239
  }
1197
1240
  this.pty.onData((data) => {
1198
- process.stdout.write(data);
1199
1241
  this.outputBuffer += data;
1200
1242
  if (this.outputBuffer.length > MAX_BUFFER_BYTES) {
1201
1243
  this.outputBuffer = this.outputBuffer.slice(this.outputBuffer.length - MAX_BUFFER_BYTES);
@@ -1231,19 +1273,33 @@ var TerminalSession = class {
1231
1273
  attach(message) {
1232
1274
  this.cols = Math.max(20, Math.floor(message.cols || this.cols));
1233
1275
  this.rows = Math.max(5, Math.floor(message.rows || this.rows));
1234
- this.pty?.resize(this.cols, this.rows);
1235
1276
  this.detachStream();
1236
1277
  const meta = { kind: "terminal", cols: this.cols, rows: this.rows };
1237
1278
  this.stream = this.channel.createStream(meta);
1238
1279
  this.batcher = new AdaptiveOutputBatcher((data) => {
1239
1280
  this.stream?.write(data);
1240
1281
  });
1241
- if (this.outputBuffer) {
1242
- this.stream.write(this.outputBuffer);
1243
- }
1244
1282
  if (!this.pty) {
1245
1283
  this.start();
1246
1284
  }
1285
+ const isFirstViewer = !this.hasHadViewer;
1286
+ if (!isFirstViewer && this.outputBuffer) {
1287
+ const REPLAY_CAP = 60 * 1024;
1288
+ const replay = this.outputBuffer.length <= REPLAY_CAP ? this.outputBuffer : this.outputBuffer.slice(this.outputBuffer.length - REPLAY_CAP);
1289
+ this.stream.write(replay);
1290
+ }
1291
+ this.outputBuffer = "";
1292
+ this.hasHadViewer = true;
1293
+ if (this.pty) {
1294
+ this.pty.resize(this.cols, this.rows);
1295
+ }
1296
+ if (isFirstViewer) {
1297
+ setTimeout(() => {
1298
+ if (this.pty && this.batcher) {
1299
+ this.pty.write("\f");
1300
+ }
1301
+ }, 250);
1302
+ }
1247
1303
  }
1248
1304
  /** End the current stream without killing the PTY (called on peer disconnect). */
1249
1305
  detachStream() {
@@ -1288,6 +1344,73 @@ function isTerminalMessage(data) {
1288
1344
  return type === "terminal_open" || type === "terminal_input" || type === "terminal_resize" || type === "terminal_close" || type === "terminal_exit";
1289
1345
  }
1290
1346
 
1347
+ // src/security.ts
1348
+ import { randomBytes, timingSafeEqual } from "node:crypto";
1349
+ var CONTROL_COOKIE_NAME = "airloom_control";
1350
+ var FixedWindowRateLimiter = class {
1351
+ entries = /* @__PURE__ */ new Map();
1352
+ allow(key, max, windowMs) {
1353
+ const now = Date.now();
1354
+ const entry = this.entries.get(key);
1355
+ if (!entry || entry.resetAt <= now) {
1356
+ this.entries.set(key, { count: 1, resetAt: now + windowMs });
1357
+ return true;
1358
+ }
1359
+ if (entry.count >= max) return false;
1360
+ entry.count += 1;
1361
+ return true;
1362
+ }
1363
+ };
1364
+ function createControlToken() {
1365
+ return randomBytes(24).toString("base64url");
1366
+ }
1367
+ function encodeControlUrl(baseUrl, token) {
1368
+ const url = new URL(baseUrl);
1369
+ url.searchParams.set("t", token);
1370
+ return url.toString();
1371
+ }
1372
+ function parseCookies(header) {
1373
+ const result = {};
1374
+ if (!header) return result;
1375
+ for (const part of header.split(";")) {
1376
+ const [rawKey, ...rawValue] = part.trim().split("=");
1377
+ if (!rawKey) continue;
1378
+ result[rawKey] = decodeURIComponent(rawValue.join("="));
1379
+ }
1380
+ return result;
1381
+ }
1382
+ function safeEqual(candidate, expected) {
1383
+ const left = Buffer.from(candidate);
1384
+ const right = Buffer.from(expected);
1385
+ if (left.length !== right.length) return false;
1386
+ return timingSafeEqual(left, right);
1387
+ }
1388
+ function readQueryToken(req) {
1389
+ if (req.query && typeof req.query.t === "string") return req.query.t;
1390
+ if (!req.url || !req.headers.host) return null;
1391
+ try {
1392
+ return new URL(req.url, `http://${req.headers.host}`).searchParams.get("t");
1393
+ } catch {
1394
+ return null;
1395
+ }
1396
+ }
1397
+ function readControlToken(req) {
1398
+ const headerToken = req.headers["x-airloom-control"];
1399
+ if (typeof headerToken === "string") return headerToken;
1400
+ const queryToken = readQueryToken(req);
1401
+ if (queryToken) return queryToken;
1402
+ return parseCookies(req.headers.cookie)[CONTROL_COOKIE_NAME] ?? null;
1403
+ }
1404
+ function hasValidControlToken(req, expected) {
1405
+ const token = readControlToken(req);
1406
+ return typeof token === "string" && safeEqual(token, expected);
1407
+ }
1408
+ function hasAllowedOrigin(headers, host) {
1409
+ const origin = headers.origin;
1410
+ if (!origin) return true;
1411
+ return origin === `http://${host}` || origin === `https://${host}`;
1412
+ }
1413
+
1291
1414
  // src/server.ts
1292
1415
  var MAX_MESSAGES = 200;
1293
1416
  function trimMessages(messages) {
@@ -1295,21 +1418,95 @@ function trimMessages(messages) {
1295
1418
  }
1296
1419
  var aiLock = Promise.resolve();
1297
1420
  function enqueueAIResponse(channel, adapter, state, broadcast) {
1298
- aiLock = aiLock.then(() => handleAIResponse(channel, adapter, state, broadcast)).catch((err) => console.error("[host] AI response error:", err));
1421
+ aiLock = aiLock.then(() => handleAIResponse(channel, adapter, state, broadcast)).catch((err) => logError("[host] AI response error:", err));
1299
1422
  }
1300
1423
  function createHostServer(opts) {
1301
1424
  const app = express();
1302
1425
  const server = createServer(app);
1303
- const wss = new WebSocketServer({ server, path: "/ws" });
1426
+ const wss = new WebSocketServer({ noServer: true });
1304
1427
  const uiClients = /* @__PURE__ */ new Set();
1305
- app.use(express.json());
1306
- app.get("/", (_req, res) => {
1307
- res.type("html").send(HOST_HTML);
1428
+ const rateLimiter = new FixedWindowRateLimiter();
1429
+ function requestKey(req) {
1430
+ return req.ip || req.socket.remoteAddress || "unknown";
1431
+ }
1432
+ function setControlCookie(req, res) {
1433
+ const parts = [
1434
+ `${CONTROL_COOKIE_NAME}=${encodeURIComponent(opts.controlToken)}`,
1435
+ "HttpOnly",
1436
+ "SameSite=Strict",
1437
+ "Path=/"
1438
+ ];
1439
+ if (req.secure) parts.push("Secure");
1440
+ res.setHeader("Set-Cookie", parts.join("; "));
1441
+ }
1442
+ function requireSameOrigin(req, res) {
1443
+ const host = req.get("host");
1444
+ if (!host || !hasAllowedOrigin(req.headers, host)) {
1445
+ res.status(403).json({ error: "Forbidden" });
1446
+ return false;
1447
+ }
1448
+ return true;
1449
+ }
1450
+ function requireControlAuth(req, res) {
1451
+ if (!requireSameOrigin(req, res)) return false;
1452
+ if (!hasValidControlToken(req, opts.controlToken)) {
1453
+ res.status(401).json({ error: "Unauthorized" });
1454
+ return false;
1455
+ }
1456
+ if (readControlToken(req) === opts.controlToken) setControlCookie(req, res);
1457
+ return true;
1458
+ }
1459
+ function requireRateLimit(req, res, bucket, max, windowMs) {
1460
+ if (rateLimiter.allow(`${bucket}:${requestKey(req)}`, max, windowMs)) return true;
1461
+ res.status(429).json({ error: "Rate limited" });
1462
+ return false;
1463
+ }
1464
+ function rejectUpgrade(socket, statusCode, message) {
1465
+ socket.write(`HTTP/1.1 ${statusCode} ${message}\r
1466
+ Connection: close\r
1467
+ Content-Type: text/plain; charset=utf-8\r
1468
+ Content-Length: ${Buffer.byteLength(message)}\r
1469
+ \r
1470
+ ${message}`);
1471
+ socket.destroy();
1472
+ }
1473
+ app.disable("x-powered-by");
1474
+ app.use(express.json({ limit: "16kb" }));
1475
+ app.use((_req, res, next) => {
1476
+ res.setHeader("Referrer-Policy", "no-referrer");
1477
+ res.setHeader("X-Content-Type-Options", "nosniff");
1478
+ res.setHeader("X-Frame-Options", "DENY");
1479
+ next();
1480
+ });
1481
+ app.get("/healthz", (_req, res) => {
1482
+ res.setHeader("Cache-Control", "no-store");
1483
+ res.json({ ok: true, connected: opts.state.connected, transport: opts.state.transport ?? "ws" });
1308
1484
  });
1309
1485
  if (opts.viewerDir && existsSync3(opts.viewerDir)) {
1310
1486
  app.use("/viewer", express.static(opts.viewerDir));
1311
1487
  }
1312
- app.get("/api/status", (_req, res) => {
1488
+ app.get("/", (req, res) => {
1489
+ if (!requireRateLimit(req, res, "root", 20, 6e4)) return;
1490
+ const host = req.get("host");
1491
+ if (!host || !hasAllowedOrigin(req.headers, host)) {
1492
+ res.status(403).type("text/plain").send("Forbidden");
1493
+ return;
1494
+ }
1495
+ if (!hasValidControlToken(req, opts.controlToken)) {
1496
+ res.status(401).type("text/plain").send("Unauthorized. Open the tokenized Airloom URL printed by the host process.");
1497
+ return;
1498
+ }
1499
+ setControlCookie(req, res);
1500
+ if (typeof req.query.t === "string") {
1501
+ res.redirect("/");
1502
+ return;
1503
+ }
1504
+ res.setHeader("Cache-Control", "no-store");
1505
+ res.type("html").send(HOST_HTML);
1506
+ });
1507
+ app.get("/api/status", (req, res) => {
1508
+ if (!requireRateLimit(req, res, "status", 60, 6e4) || !requireControlAuth(req, res)) return;
1509
+ res.setHeader("Cache-Control", "no-store");
1313
1510
  res.json({
1314
1511
  connected: opts.state.connected,
1315
1512
  pairingCode: opts.state.pairingCode,
@@ -1320,11 +1517,15 @@ function createHostServer(opts) {
1320
1517
  messages: opts.state.messages
1321
1518
  });
1322
1519
  });
1323
- app.get("/api/cli-presets", (_req, res) => {
1520
+ app.get("/api/cli-presets", (req, res) => {
1521
+ if (!requireRateLimit(req, res, "cli-presets", 60, 6e4) || !requireControlAuth(req, res)) return;
1522
+ res.setHeader("Cache-Control", "no-store");
1324
1523
  res.json(CLI_PRESETS);
1325
1524
  });
1326
- app.get("/api/config", (_req, res) => {
1525
+ app.get("/api/config", (req, res) => {
1526
+ if (!requireRateLimit(req, res, "config-get", 60, 6e4) || !requireControlAuth(req, res)) return;
1327
1527
  const saved = loadConfig();
1528
+ res.setHeader("Cache-Control", "no-store");
1328
1529
  res.json({
1329
1530
  saved,
1330
1531
  envKeys: {
@@ -1333,32 +1534,52 @@ function createHostServer(opts) {
1333
1534
  }
1334
1535
  });
1335
1536
  });
1537
+ const allowedPresetIds = /* @__PURE__ */ new Set(["shell", ...CLI_PRESETS.map((preset) => preset.id)]);
1538
+ function readString(value, maxLength) {
1539
+ if (typeof value !== "string") return void 0;
1540
+ const trimmed = value.trim();
1541
+ if (!trimmed) return void 0;
1542
+ return trimmed.length <= maxLength ? trimmed : void 0;
1543
+ }
1336
1544
  app.post("/api/configure", (req, res) => {
1545
+ if (!requireRateLimit(req, res, "configure", 10, 6e4) || !requireControlAuth(req, res)) return;
1546
+ res.setHeader("Cache-Control", "no-store");
1337
1547
  const { type, apiKey, model, command, preset } = req.body;
1548
+ if (type !== "anthropic" && type !== "openai" && type !== "cli") {
1549
+ res.status(400).json({ error: "Unknown adapter type" });
1550
+ return;
1551
+ }
1552
+ const normalizedApiKey = readString(apiKey, 512);
1553
+ const normalizedModel = readString(model, 200);
1554
+ const normalizedCommand = readString(command, 512);
1555
+ const selectedPreset = typeof preset === "string" ? preset : "shell";
1556
+ if (!allowedPresetIds.has(selectedPreset)) {
1557
+ res.status(400).json({ error: "Unknown preset" });
1558
+ return;
1559
+ }
1338
1560
  try {
1339
1561
  switch (type) {
1340
1562
  case "anthropic": {
1341
- const key = apiKey || process.env.ANTHROPIC_API_KEY;
1563
+ const key = normalizedApiKey || process.env.ANTHROPIC_API_KEY;
1342
1564
  if (!key) {
1343
1565
  res.status(400).json({ error: "API key required (or set ANTHROPIC_API_KEY env var)" });
1344
1566
  return;
1345
1567
  }
1346
- opts.state.adapter = new AnthropicAdapter({ apiKey: key, model });
1568
+ opts.state.adapter = new AnthropicAdapter({ apiKey: key, model: normalizedModel });
1347
1569
  break;
1348
1570
  }
1349
1571
  case "openai": {
1350
- const key = apiKey || process.env.OPENAI_API_KEY;
1572
+ const key = normalizedApiKey || process.env.OPENAI_API_KEY;
1351
1573
  if (!key) {
1352
1574
  res.status(400).json({ error: "API key required (or set OPENAI_API_KEY env var)" });
1353
1575
  return;
1354
1576
  }
1355
- opts.state.adapter = new OpenAIAdapter({ apiKey: key, model });
1577
+ opts.state.adapter = new OpenAIAdapter({ apiKey: key, model: normalizedModel });
1356
1578
  break;
1357
1579
  }
1358
1580
  case "cli": {
1359
- const selectedPreset = typeof preset === "string" ? preset : "shell";
1360
1581
  const presetInfo = CLI_PRESETS.find((p) => p.id === selectedPreset);
1361
- const cmd = selectedPreset === "shell" ? void 0 : typeof command === "string" && command.trim() ? command.trim() : presetInfo?.command;
1582
+ const cmd = selectedPreset === "shell" ? void 0 : normalizedCommand ?? presetInfo?.command;
1362
1583
  opts.state.terminalLaunchCommand = cmd;
1363
1584
  opts.state.terminalLaunch = getTerminalLaunchDisplay(cmd);
1364
1585
  opts.state.adapter = null;
@@ -1368,15 +1589,12 @@ function createHostServer(opts) {
1368
1589
  res.json({ ok: true, terminalLaunch: opts.state.terminalLaunch });
1369
1590
  return;
1370
1591
  }
1371
- default:
1372
- res.status(400).json({ error: "Unknown adapter type" });
1373
- return;
1374
1592
  }
1375
1593
  const cfg = { type };
1376
- if (model) cfg.model = model;
1594
+ if (normalizedModel) cfg.model = normalizedModel;
1377
1595
  if (type === "cli") {
1378
- cfg.command = command;
1379
- cfg.preset = preset;
1596
+ cfg.command = normalizedCommand;
1597
+ cfg.preset = selectedPreset;
1380
1598
  }
1381
1599
  saveConfig(cfg);
1382
1600
  broadcast({ type: "configured", adapter: { name: opts.state.adapter?.name ?? "none", model: opts.state.adapter?.model ?? "" } });
@@ -1387,7 +1605,9 @@ function createHostServer(opts) {
1387
1605
  }
1388
1606
  });
1389
1607
  app.post("/api/send", async (req, res) => {
1390
- const { content } = req.body;
1608
+ if (!requireRateLimit(req, res, "send", 30, 6e4) || !requireControlAuth(req, res)) return;
1609
+ res.setHeader("Cache-Control", "no-store");
1610
+ const content = readString(req.body?.content, 4e3);
1391
1611
  if (!content) {
1392
1612
  res.status(400).json({ error: "No content" });
1393
1613
  return;
@@ -1401,30 +1621,73 @@ function createHostServer(opts) {
1401
1621
  res.json({ ok: true });
1402
1622
  });
1403
1623
  app.get("/api/pair", (req, res) => {
1624
+ if (!requireRateLimit(req, res, "pair", 12, 6e4)) return;
1625
+ const host = req.get("host");
1626
+ if (!host || !hasAllowedOrigin(req.headers, host)) {
1627
+ res.status(403).json({ error: "Forbidden" });
1628
+ return;
1629
+ }
1404
1630
  const { session } = req.query;
1405
- if (!session || session !== opts.state.sessionToken) {
1631
+ if (!session || !/^[0-9a-f]{32}$/i.test(session) || session !== opts.state.pairingSessionToken) {
1406
1632
  res.status(401).json({ error: "Invalid session" });
1407
1633
  return;
1408
1634
  }
1635
+ if (!opts.state.relaySessionToken) {
1636
+ res.status(503).json({ error: "Pairing unavailable" });
1637
+ return;
1638
+ }
1639
+ res.setHeader("Cache-Control", "no-store");
1409
1640
  res.json({
1641
+ session: opts.state.relaySessionToken,
1410
1642
  token: opts.state.ablyToken,
1643
+ tokenExpiresAt: opts.state.ablyTokenExpiresAt,
1411
1644
  transport: opts.state.transport ?? "ws",
1412
1645
  relay: opts.state.relayUrl
1413
1646
  });
1414
1647
  });
1648
+ server.on("upgrade", (req, socket, head) => {
1649
+ const host = req.headers.host;
1650
+ if (!host) {
1651
+ rejectUpgrade(socket, 400, "Bad Request");
1652
+ return;
1653
+ }
1654
+ let url;
1655
+ try {
1656
+ url = new URL(req.url ?? "/", `http://${host}`);
1657
+ } catch {
1658
+ rejectUpgrade(socket, 400, "Bad Request");
1659
+ return;
1660
+ }
1661
+ if (url.pathname !== "/ws") {
1662
+ socket.destroy();
1663
+ return;
1664
+ }
1665
+ if (!hasAllowedOrigin(req.headers, host)) {
1666
+ rejectUpgrade(socket, 403, "Forbidden");
1667
+ return;
1668
+ }
1669
+ if (!hasValidControlToken(req, opts.controlToken)) {
1670
+ rejectUpgrade(socket, 401, "Unauthorized");
1671
+ return;
1672
+ }
1673
+ wss.handleUpgrade(req, socket, head, (ws) => {
1674
+ wss.emit("connection", ws, req);
1675
+ });
1676
+ });
1415
1677
  wss.on("connection", (ws) => {
1416
1678
  uiClients.add(ws);
1417
1679
  ws.on("close", () => uiClients.delete(ws));
1418
1680
  ws.on("message", (data) => {
1419
1681
  try {
1682
+ if (data.toString().length > 16384) return;
1420
1683
  const message = JSON.parse(data.toString());
1421
- if (message.type === "terminal_input" && typeof message.data === "string") {
1684
+ if (message.type === "terminal_input" && typeof message.data === "string" && message.data.length <= 8192) {
1422
1685
  opts.state.terminal?.writeRawInput(message.data);
1423
- } else if (message.type === "terminal_resize") {
1686
+ } else if (message.type === "terminal_resize" && Number.isInteger(message.cols) && Number.isInteger(message.rows) && message.cols > 0 && message.rows > 0) {
1424
1687
  opts.state.terminal?.handleMessage(message);
1425
1688
  }
1426
1689
  } catch (err) {
1427
- console.error("[host] Invalid WebSocket message:", err);
1690
+ logError("[host] Invalid WebSocket message:", err);
1428
1691
  }
1429
1692
  });
1430
1693
  });
@@ -1434,16 +1697,35 @@ function createHostServer(opts) {
1434
1697
  if (ws.readyState === WebSocket.OPEN) ws.send(msg);
1435
1698
  }
1436
1699
  }
1437
- return new Promise((resolve4) => {
1438
- server.listen(opts.port, "0.0.0.0", () => {
1439
- const addr = server.address();
1440
- const actualPort = typeof addr === "object" && addr ? addr.port : opts.port;
1441
- resolve4({ server, broadcast, port: actualPort });
1442
- });
1443
- server.on("error", (err) => {
1444
- console.error(`[host] Server error: ${err.message}`);
1445
- process.exit(1);
1446
- });
1700
+ return new Promise((resolve4, reject) => {
1701
+ const MAX_PORT_ATTEMPTS = 20;
1702
+ let attempt = 0;
1703
+ let port = opts.port;
1704
+ function tryListen() {
1705
+ server.once("error", onError);
1706
+ server.listen(port, opts.bind, () => {
1707
+ server.removeListener("error", onError);
1708
+ const addr = server.address();
1709
+ const actualPort = typeof addr === "object" && addr ? addr.port : port;
1710
+ resolve4({ server, broadcast, port: actualPort });
1711
+ });
1712
+ }
1713
+ function onError(err) {
1714
+ server.removeListener("error", onError);
1715
+ if (err.code === "EADDRINUSE" && attempt < MAX_PORT_ATTEMPTS) {
1716
+ attempt++;
1717
+ port++;
1718
+ if (port > 65535) {
1719
+ reject(err);
1720
+ return;
1721
+ }
1722
+ log(`[host] Port ${port - 1} in use, trying ${port}...`);
1723
+ tryListen();
1724
+ } else {
1725
+ reject(err);
1726
+ }
1727
+ }
1728
+ tryListen();
1447
1729
  });
1448
1730
  }
1449
1731
  async function handleAIResponse(channel, adapter, state, broadcast) {
@@ -1493,14 +1775,19 @@ var HOST_HTML = `<!DOCTYPE html>
1493
1775
  <title>Airloom - Host</title>
1494
1776
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/css/xterm.css">
1495
1777
  <style>
1496
- :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:#fff;--tool-bg:#e5e5ea;--tool-hover:#d1d1d6;--msg-user:#d6d5f7;--msg-asst:#f2f2f7}}
1778
+ :root{color-scheme: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}
1779
+ [data-theme="light"]{color-scheme:light;--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:#d1d1d6;--tool-hover:#c0c0c5;--msg-user:#d6d5f7;--msg-asst:#f2f2f7}
1780
+ @media(prefers-color-scheme:light){:root:not([data-theme="dark"]){color-scheme:light;--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:#d1d1d6;--tool-hover:#c0c0c5;--msg-user:#d6d5f7;--msg-asst:#f2f2f7}}
1498
1781
  *{margin:0;padding:0;box-sizing:border-box}
1499
1782
  body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
1500
1783
  .container{max-width:800px;margin:0 auto;padding:20px}
1501
1784
  .page-header{display:flex;align-items:center;gap:12px;margin-bottom:20px}
1502
1785
  .page-header svg{width:36px;height:36px;flex-shrink:0}
1503
- .page-header h1{font-size:1.5rem;color:var(--accent)}
1786
+ .page-header h1{font-size:1.5rem;color:var(--accent);flex:1}
1787
+ .theme-switch{display:flex;gap:2px;background:var(--border);border-radius:8px;padding:2px}
1788
+ .theme-btn{padding:5px 10px;font-size:.8rem;background:transparent;border:none;border-radius:6px;color:var(--text-muted);cursor:pointer;font-weight:500;line-height:1}
1789
+ .theme-btn:hover{color:var(--text)}
1790
+ .theme-btn.active{background:var(--surface);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.15)}
1504
1791
  h2{font-size:1.1rem;margin-bottom:12px;color:var(--text-muted)}
1505
1792
  .card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:20px;margin-bottom:16px}
1506
1793
  .status{display:flex;align-items:center;gap:8px;margin-bottom:8px}
@@ -1522,9 +1809,10 @@ var HOST_HTML = `<!DOCTYPE html>
1522
1809
  .input-area{display:flex;gap:8px}
1523
1810
  .input-area textarea{flex:1;resize:none;min-height:44px;max-height:120px;padding:10px 14px;border-radius:8px;border:1px solid var(--input-border);background:var(--input-bg);color:var(--text);font-family:inherit;font-size:.9rem}
1524
1811
  .terminal-container{background:var(--term-bg);border:1px solid var(--border);border-radius:8px;height:420px;overflow:hidden;margin-bottom:12px}
1525
- #terminal{width:100%;height:100%;padding:8px}
1812
+ #terminal{width:100%;height:100%;padding:8px;background:var(--term-bg)}
1813
+ #terminal .xterm,#terminal .xterm-viewport{background-color:var(--term-bg) !important}
1526
1814
  .toolbar{display:flex;gap:8px;margin-bottom:12px}
1527
- .tool-btn{padding:6px 12px;font-size:.85rem;background:var(--tool-bg);border:none;border-radius:6px;color:var(--text);cursor:pointer}
1815
+ .tool-btn{padding:6px 12px;font-size:.85rem;font-weight:normal;background:var(--tool-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);cursor:pointer}
1528
1816
  .tool-btn:hover{background:var(--tool-hover)}
1529
1817
  </style>
1530
1818
  </head>
@@ -1533,6 +1821,11 @@ var HOST_HTML = `<!DOCTYPE html>
1533
1821
  <div class="page-header">
1534
1822
  <svg viewBox="0 0 100 100" fill="none"><defs><linearGradient id="lg" x1=".3" y1="0" x2=".7" y2="1"><stop stop-color="#a0aaff"/><stop offset="1" stop-color="#6070ef"/></linearGradient></defs><g stroke="url(#lg)" stroke-width="6" stroke-linecap="round"><line x1="22" y1="88" x2="31" y2="64"/><line x1="36" y1="52" x2="50" y2="15"/><line x1="9" y1="58" x2="59.5" y2="58"/><line x1="73.5" y1="58" x2="91" y2="58"/><line x1="50" y1="15" x2="78" y2="88"/></g></svg>
1535
1823
  <h1>Airloom</h1>
1824
+ <div class="theme-switch" id="themeSwitch">
1825
+ <button class="theme-btn" data-mode="light" title="Light">Light</button>
1826
+ <button class="theme-btn" data-mode="dark" title="Dark">Dark</button>
1827
+ <button class="theme-btn active" data-mode="system" title="System">Auto</button>
1828
+ </div>
1536
1829
  </div>
1537
1830
  <div class="card">
1538
1831
  <div class="status"><div class="dot wait" id="dot"></div><span id="statusText">Initializing...</span></div>
@@ -1610,9 +1903,43 @@ const lightTheme = {
1610
1903
  brightBlack: '#6e6e73', brightRed: '#eb4d3d', brightGreen: '#36b738', brightYellow: '#b79a14',
1611
1904
  brightBlue: '#0451a5', brightMagenta: '#c42275', brightCyan: '#318495', brightWhite: '#f2f2f7',
1612
1905
  };
1613
- function getTheme() { return matchMedia('(prefers-color-scheme:light)').matches ? lightTheme : darkTheme; }
1614
- matchMedia('(prefers-color-scheme:light)').addEventListener('change', () => {
1906
+
1907
+ // --- Theme management ---
1908
+ function isEffectivelyLight() {
1909
+ const mode = document.documentElement.dataset.theme;
1910
+ if (mode === 'light') return true;
1911
+ if (mode === 'dark') return false;
1912
+ return matchMedia('(prefers-color-scheme:light)').matches;
1913
+ }
1914
+ function getTheme() { return isEffectivelyLight() ? lightTheme : darkTheme; }
1915
+
1916
+ function applyTheme(mode) {
1917
+ if (mode === 'light' || mode === 'dark') {
1918
+ document.documentElement.dataset.theme = mode;
1919
+ } else {
1920
+ delete document.documentElement.dataset.theme;
1921
+ mode = 'system';
1922
+ }
1923
+ localStorage.setItem('airloom-theme', mode);
1615
1924
  if (term) term.options.theme = getTheme();
1925
+ document.querySelectorAll('.theme-btn').forEach(b => {
1926
+ b.classList.toggle('active', b.dataset.mode === mode);
1927
+ });
1928
+ }
1929
+
1930
+ // Restore saved preference (or default to system)
1931
+ applyTheme(localStorage.getItem('airloom-theme') || 'system');
1932
+
1933
+ document.getElementById('themeSwitch').addEventListener('click', (e) => {
1934
+ const btn = e.target.closest('.theme-btn');
1935
+ if (btn) applyTheme(btn.dataset.mode);
1936
+ });
1937
+ matchMedia('(prefers-color-scheme:light)').addEventListener('change', () => {
1938
+ // Only react if the user chose "system"
1939
+ const saved = localStorage.getItem('airloom-theme');
1940
+ if (!saved || saved === 'system') {
1941
+ if (term) term.options.theme = getTheme();
1942
+ }
1616
1943
  });
1617
1944
 
1618
1945
  function initTerminal() {
@@ -1642,6 +1969,10 @@ function initTerminal() {
1642
1969
  ws.send(JSON.stringify({ type: 'terminal_resize', cols: term.cols, rows: term.rows }));
1643
1970
  }
1644
1971
  }).observe(document.getElementById('terminalContainer'));
1972
+ // Prevent wheel events from leaking to the page when the terminal is at its
1973
+ // scroll bounds. xterm.js v6 uses a SmoothScrollableElement that doesn't
1974
+ // always consume wheel events at the extents.
1975
+ document.getElementById('terminalContainer').addEventListener('wheel', (e) => { e.preventDefault(); }, { passive: false });
1645
1976
  if (ws.readyState === WebSocket.OPEN) sendTerminalOpen();
1646
1977
  }
1647
1978
 
@@ -1794,13 +2125,114 @@ function sendMessage() {
1794
2125
  </body>
1795
2126
  </html>`;
1796
2127
 
2128
+ // src/env.ts
2129
+ import { isIP } from "node:net";
2130
+ var DEFAULT_ABLY_KEY = "SfHSAQ.IRTOQQ:FBbi9a7ZV6jIu0Gdo_UeYhIN4rzpMrud5-LldURNh9s";
2131
+ var DEFAULT_VIEWER_URL = "https://bobstrogg.github.io/Airloom/";
2132
+ var DEFAULT_ABLY_TOKEN_TTL_MS = 24 * 60 * 60 * 1e3;
2133
+ var DEFAULT_HOST_BIND = "127.0.0.1";
2134
+ var DEFAULT_HOST_PORT = 4e3;
2135
+ function parseInteger(name, value, fallback, min, max) {
2136
+ if (value === void 0 || value === "") return fallback;
2137
+ const parsed = Number.parseInt(value, 10);
2138
+ if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
2139
+ throw new Error(`${name} must be an integer between ${min} and ${max}`);
2140
+ }
2141
+ return parsed;
2142
+ }
2143
+ function parseViewerUrl(value) {
2144
+ const candidate = value?.trim() || DEFAULT_VIEWER_URL;
2145
+ let parsed;
2146
+ try {
2147
+ parsed = new URL(candidate);
2148
+ } catch {
2149
+ throw new Error("VIEWER_URL must be a valid http:// or https:// URL");
2150
+ }
2151
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
2152
+ throw new Error("VIEWER_URL must use http:// or https://");
2153
+ }
2154
+ return parsed.toString();
2155
+ }
2156
+ function parseRelayUrl(value) {
2157
+ if (!value?.trim()) return void 0;
2158
+ let parsed;
2159
+ try {
2160
+ parsed = new URL(value.trim());
2161
+ } catch {
2162
+ throw new Error("RELAY_URL must be a valid ws:// or wss:// URL");
2163
+ }
2164
+ if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
2165
+ throw new Error("RELAY_URL must use ws:// or wss://");
2166
+ }
2167
+ return parsed.toString();
2168
+ }
2169
+ function parseHostBind(value, isDev) {
2170
+ const bind = value?.trim() || (isDev ? "0.0.0.0" : DEFAULT_HOST_BIND);
2171
+ if (bind === "localhost" || isIP(bind) !== 0) return bind;
2172
+ if (/^[A-Za-z0-9.-]+$/.test(bind)) return bind;
2173
+ throw new Error("HOST_BIND must be localhost, an IP address, or a hostname");
2174
+ }
2175
+ function isLoopbackBind(bind) {
2176
+ return bind === "127.0.0.1" || bind === "::1" || bind === "localhost";
2177
+ }
2178
+ function parseHostEnv(cliPort, isDev = false) {
2179
+ const relayUrl = parseRelayUrl(process.env.RELAY_URL);
2180
+ const ablyApiKey = process.env.ABLY_API_KEY ?? (relayUrl ? void 0 : DEFAULT_ABLY_KEY);
2181
+ const ablyTokenTtlMs = parseInteger("ABLY_TOKEN_TTL", process.env.ABLY_TOKEN_TTL, DEFAULT_ABLY_TOKEN_TTL_MS, 6e4, 31 * 24 * 60 * 60 * 1e3);
2182
+ const hostPort = cliPort ?? parseInteger("HOST_PORT", process.env.HOST_PORT, DEFAULT_HOST_PORT, 0, 65535);
2183
+ const hostBind = parseHostBind(process.env.HOST_BIND, isDev);
2184
+ const viewerUrl = parseViewerUrl(process.env.VIEWER_URL);
2185
+ return {
2186
+ viewerUrl,
2187
+ relayUrl,
2188
+ ablyApiKey,
2189
+ ablyTokenTtlMs,
2190
+ hostPort,
2191
+ hostBind,
2192
+ useAbly: !!ablyApiKey,
2193
+ isDefaultAblyKey: !!ablyApiKey && ablyApiKey === DEFAULT_ABLY_KEY
2194
+ };
2195
+ }
2196
+
2197
+ // src/state.ts
2198
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
2199
+ import { randomBytes as randomBytes2 } from "node:crypto";
2200
+ import { homedir as homedir2 } from "node:os";
2201
+ import { join as join4 } from "node:path";
2202
+ var STATE_DIR = join4(homedir2(), ".config", "airloom");
2203
+ var STATE_PATH = join4(STATE_DIR, "state.json");
2204
+ function readState() {
2205
+ try {
2206
+ const raw = readFileSync2(STATE_PATH, "utf-8");
2207
+ const data = JSON.parse(raw);
2208
+ return data && typeof data === "object" ? data : {};
2209
+ } catch {
2210
+ return {};
2211
+ }
2212
+ }
2213
+ function writeState(state) {
2214
+ mkdirSync2(STATE_DIR, { recursive: true });
2215
+ writeFileSync2(STATE_PATH, JSON.stringify(state, null, 2) + "\n", "utf-8");
2216
+ }
2217
+ function isSessionToken(value) {
2218
+ return typeof value === "string" && /^[0-9a-f]{32}$/i.test(value);
2219
+ }
2220
+ function loadOrCreateAblySessionToken() {
2221
+ const state = readState();
2222
+ if (isSessionToken(state.ablySessionToken)) return state.ablySessionToken;
2223
+ const ablySessionToken = randomBytes2(16).toString("hex");
2224
+ state.ablySessionToken = ablySessionToken;
2225
+ writeState(state);
2226
+ return ablySessionToken;
2227
+ }
2228
+
1797
2229
  // src/index.ts
1798
2230
  var QRCode = null;
1799
2231
  async function getQRCode() {
1800
2232
  if (!QRCode) QRCode = await import("qrcode");
1801
2233
  return QRCode;
1802
2234
  }
1803
- console.log("[host] Module loaded");
2235
+ log("[host] Module loaded");
1804
2236
  function parseArgs(argv) {
1805
2237
  const args = {};
1806
2238
  const rest = argv.slice(2);
@@ -1851,7 +2283,8 @@ Environment variables:
1851
2283
  ABLY_API_KEY Your own Ably key (overrides default community relay).
1852
2284
  RELAY_URL Self-hosted WebSocket relay URL (disables Ably).
1853
2285
  VIEWER_URL Public viewer URL (default: GitHub Pages).
1854
- HOST_PORT Same as --port (CLI flag takes precedence).
2286
+ HOST_PORT Same as --port (default: 4000, auto-increments if in use).
2287
+ HOST_BIND Host bind address (default: 127.0.0.1).
1855
2288
  `.trimStart());
1856
2289
  }
1857
2290
  var cliArgs = parseArgs(process.argv);
@@ -1859,16 +2292,16 @@ if (cliArgs.help) {
1859
2292
  printHelp();
1860
2293
  process.exit(0);
1861
2294
  }
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
2295
  var IS_DEV = !process.env.VIEWER_URL && !new URL(import.meta.url).pathname.includes("node_modules");
1866
- var RELAY_URL = process.env.RELAY_URL;
1867
- var ABLY_API_KEY = process.env.ABLY_API_KEY ?? (RELAY_URL ? void 0 : DEFAULT_ABLY_KEY);
1868
- var ABLY_TOKEN_TTL = parseInt(process.env.ABLY_TOKEN_TTL ?? String(24 * 60 * 60 * 1e3), 10);
1869
- var HOST_PORT = cliArgs.port ?? parseInt(process.env.HOST_PORT ?? "0", 10);
1870
- var useAbly = !!ABLY_API_KEY;
1871
- var isDefaultKey = useAbly && ABLY_API_KEY === DEFAULT_ABLY_KEY;
2296
+ var env = parseHostEnv(cliArgs.port, IS_DEV);
2297
+ var VIEWER_URL = env.viewerUrl;
2298
+ var RELAY_URL = env.relayUrl;
2299
+ var ABLY_API_KEY = env.ablyApiKey;
2300
+ var ABLY_TOKEN_TTL = env.ablyTokenTtlMs;
2301
+ var HOST_PORT = env.hostPort;
2302
+ var HOST_BIND = env.hostBind;
2303
+ var useAbly = env.useAbly;
2304
+ var isDefaultKey = env.isDefaultAblyKey;
1872
2305
  var __dirname = dirname2(fileURLToPath(import.meta.url));
1873
2306
  function getLanIP() {
1874
2307
  for (const ifaces of Object.values(networkInterfaces())) {
@@ -1890,41 +2323,58 @@ async function main() {
1890
2323
  console.log("==============\n");
1891
2324
  if (useAbly) {
1892
2325
  if (isDefaultKey) {
1893
- console.log("Transport: Ably (community relay \u2014 shared quota)");
1894
- console.log(" Set ABLY_API_KEY for your own quota, or RELAY_URL for self-hosted.\n");
2326
+ log("Transport: Ably (community relay \u2014 shared quota)");
2327
+ log(" Set ABLY_API_KEY for your own quota, or RELAY_URL for self-hosted.\n");
1895
2328
  } else {
1896
- console.log("Transport: Ably (your key)");
2329
+ log("Transport: Ably (your key)");
1897
2330
  }
1898
2331
  } else {
1899
- console.log(`Transport: WebSocket (self-hosted relay at ${RELAY_URL})`);
2332
+ log(`Transport: WebSocket (self-hosted relay at ${RELAY_URL})`);
1900
2333
  }
1901
- const session = createSession(useAbly ? "ably" : RELAY_URL);
1902
- const displayCode = formatPairingCode(session.pairingCode);
2334
+ let pairingCode;
2335
+ let pairingSessionToken;
2336
+ let relaySessionToken;
1903
2337
  let pairingData;
2338
+ let ablyToken;
2339
+ let ablyTokenExpiresAt;
1904
2340
  if (useAbly) {
2341
+ pairingCode = randomCode(8);
2342
+ pairingSessionToken = deriveSessionToken(pairingCode);
2343
+ relaySessionToken = loadOrCreateAblySessionToken();
2344
+ const keyPair = generateKeyPair();
1905
2345
  const { Rest } = await import("ably");
1906
2346
  const rest = new Rest({
1907
2347
  key: ABLY_API_KEY,
1908
2348
  queryTime: true
1909
2349
  });
1910
- const channelName = `airloom:${session.sessionToken}`;
2350
+ const channelName = `airloom:${relaySessionToken}`;
1911
2351
  const tokenDetails = await rest.auth.requestToken({
1912
2352
  clientId: "*",
1913
- // viewer picks its own clientId
1914
- capability: { "airloom:*": ["publish", "subscribe", "presence"] },
2353
+ capability: { [channelName]: ["publish", "subscribe", "presence"] },
1915
2354
  ttl: ABLY_TOKEN_TTL
1916
2355
  });
1917
- console.log(`[ably] Scoped token issued (TTL: ${Math.round(ABLY_TOKEN_TTL / 6e4)}min, channel: ${channelName})`);
2356
+ log(`[ably] Scoped token issued (TTL: ${Math.round(ABLY_TOKEN_TTL / 6e4)}min, channel: ${channelName})`);
2357
+ ablyToken = tokenDetails.token;
2358
+ ablyTokenExpiresAt = tokenDetails.expires;
1918
2359
  pairingData = {
1919
- ...session.pairingData,
2360
+ relay: "ably",
2361
+ session: relaySessionToken,
2362
+ pub: toBase64(keyPair.publicKey),
2363
+ v: 1,
1920
2364
  transport: "ably",
1921
- token: tokenDetails.token
2365
+ token: ablyToken,
2366
+ tokenExpiresAt: ablyTokenExpiresAt
1922
2367
  };
1923
2368
  } else {
2369
+ const session = createSession(RELAY_URL);
2370
+ pairingCode = session.pairingCode;
2371
+ pairingSessionToken = session.sessionToken;
2372
+ relaySessionToken = session.sessionToken;
1924
2373
  pairingData = { ...session.pairingData };
1925
2374
  }
2375
+ const displayCode = formatPairingCode(pairingCode);
1926
2376
  const pairingJSON = encodePairingData(pairingData);
1927
- const keyMaterial = sha2562(new TextEncoder().encode("airloom-key:" + session.sessionToken));
2377
+ const keyMaterial = sha2562(new TextEncoder().encode("airloom-key:" + relaySessionToken));
1928
2378
  const encryptionKey = deriveEncryptionKey(keyMaterial);
1929
2379
  let adapter;
1930
2380
  if (useAbly) {
@@ -1937,12 +2387,12 @@ async function main() {
1937
2387
  role: "host",
1938
2388
  encryptionKey
1939
2389
  });
1940
- await channel.connect(session.sessionToken);
1941
- console.log("[host] Connected to relay, waiting for phone...");
2390
+ await channel.connect(relaySessionToken);
2391
+ log("[host] Connected to relay, waiting for phone...");
1942
2392
  const savedConfig = loadConfig();
1943
2393
  const launchPreset = cliArgs.preset ? CLI_PRESETS.find((p) => p.id === cliArgs.preset) : void 0;
1944
2394
  if (cliArgs.preset && !launchPreset) {
1945
- console.error(`[host] Unknown preset "${cliArgs.preset}". Available: ${CLI_PRESETS.map((p) => p.id).join(", ")}`);
2395
+ logError(`[host] Unknown preset "${cliArgs.preset}". Available: ${CLI_PRESETS.map((p) => p.id).join(", ")}`);
1946
2396
  process.exit(1);
1947
2397
  }
1948
2398
  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 +2409,13 @@ async function main() {
1959
2409
  terminalLaunch,
1960
2410
  terminalLaunchCommand: launchCommand,
1961
2411
  messages: [],
1962
- sessionToken: session.sessionToken,
1963
- ablyToken: useAbly ? pairingData.token : void 0,
2412
+ pairingSessionToken,
2413
+ relaySessionToken,
2414
+ ablyToken,
2415
+ ablyTokenExpiresAt,
1964
2416
  transport: useAbly ? "ably" : "ws"
1965
2417
  };
1966
- console.log(`[host] Terminal launch: ${terminalLaunch}`);
2418
+ log(`[host] Terminal launch: ${terminalLaunch}`);
1967
2419
  if (cliArgs.cli || cliArgs.preset) {
1968
2420
  let command = cliArgs.cli;
1969
2421
  const presetInfo = launchPreset;
@@ -1974,7 +2426,7 @@ async function main() {
1974
2426
  mode: presetInfo?.mode,
1975
2427
  silenceTimeout: presetInfo?.silenceTimeout
1976
2428
  });
1977
- console.log(`[host] CLI adapter: ${command} (${presetInfo?.mode ?? "oneshot"})`);
2429
+ log(`[host] CLI adapter: ${command} (${presetInfo?.mode ?? "oneshot"})`);
1978
2430
  }
1979
2431
  } else {
1980
2432
  const saved = loadConfig();
@@ -2010,28 +2462,35 @@ async function main() {
2010
2462
  }
2011
2463
  }
2012
2464
  if (state.adapter) {
2013
- console.log(`[host] Auto-configured: ${state.adapter.name} (${state.adapter.model})`);
2014
- console.log(` Loaded from ${getConfigPath()}`);
2465
+ log(`[host] Auto-configured: ${state.adapter.name} (${state.adapter.model})`);
2466
+ log(` Loaded from ${getConfigPath()}`);
2015
2467
  }
2016
2468
  } catch (err) {
2017
- console.error("[host] Auto-configure failed:", err.message);
2469
+ logError("[host] Auto-configure failed:", err.message);
2018
2470
  }
2019
2471
  }
2020
2472
  }
2021
2473
  const viewerDir = resolveViewerDir();
2022
2474
  if (viewerDir) {
2023
- console.log(`[host] Viewer files: ${viewerDir}`);
2475
+ log(`[host] Viewer files: ${viewerDir}`);
2024
2476
  } else {
2025
- console.log("[host] Viewer dist not found \u2014 QR will open raw JSON fallback");
2026
- }
2027
- const { server, broadcast, port } = await createHostServer({ port: HOST_PORT, state, viewerDir });
2477
+ log("[host] Viewer dist not found \u2014 QR will open raw JSON fallback");
2478
+ }
2479
+ const controlToken = createControlToken();
2480
+ const { server, broadcast, port } = await createHostServer({
2481
+ port: HOST_PORT,
2482
+ bind: HOST_BIND,
2483
+ controlToken,
2484
+ state,
2485
+ viewerDir
2486
+ });
2028
2487
  const pairingBase64 = Buffer.from(pairingJSON).toString("base64url");
2029
2488
  const viewerBase = VIEWER_URL.replace(/\/+$/, "");
2030
2489
  const pagesUrl = `${viewerBase}/#${pairingBase64}`;
2031
2490
  const lanIP = getLanIP();
2032
2491
  const lanHost = lanIP ?? "localhost";
2033
2492
  const lanBaseUrl = `http://${lanHost}:${port}`;
2034
- const lanViewerUrl = viewerDir ? `${lanBaseUrl}/viewer/#${pairingBase64}` : null;
2493
+ const lanViewerUrl = viewerDir && !isLoopbackBind(HOST_BIND) ? `${lanBaseUrl}/viewer/#${pairingBase64}` : null;
2035
2494
  const qrTarget = IS_DEV && lanViewerUrl ? lanViewerUrl : pagesUrl;
2036
2495
  const qrcode = await getQRCode();
2037
2496
  const qrDataUrl = await qrcode.toDataURL(qrTarget, { width: 300, margin: 2 });
@@ -2049,36 +2508,50 @@ async function main() {
2049
2508
  }
2050
2509
  if (!useAbly) console.log(`Relay: ${RELAY_URL}`);
2051
2510
  const localUrl = `http://localhost:${port}`;
2052
- console.log(`[host] Web UI at ${localUrl}
2511
+ const controlUrl = encodeControlUrl(localUrl, controlToken);
2512
+ log(`[host] Web UI at ${controlUrl}
2053
2513
  `);
2054
2514
  import("node:child_process").then(({ exec }) => {
2055
2515
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
2056
- exec(`${cmd} ${localUrl}`);
2516
+ exec(`${cmd} ${controlUrl}`);
2057
2517
  }).catch(() => {
2058
2518
  });
2059
2519
  const terminal = new TerminalSession(channel, () => state.terminalLaunchCommand, broadcast);
2060
2520
  state.terminal = terminal;
2521
+ const pushViewerSession = () => {
2522
+ if (!state.relaySessionToken || !state.transport) return;
2523
+ const refresh = {
2524
+ type: "session_refresh",
2525
+ relay: state.relayUrl,
2526
+ session: state.relaySessionToken,
2527
+ transport: state.transport,
2528
+ token: state.ablyToken,
2529
+ tokenExpiresAt: state.ablyTokenExpiresAt
2530
+ };
2531
+ channel.send(refresh);
2532
+ };
2061
2533
  channel.on("ready", () => {
2062
- console.log("[host] Phone connected! Channel ready.");
2534
+ log("[host] Phone connected! Channel ready.");
2063
2535
  state.connected = true;
2064
2536
  broadcast({ type: "peer_connected" });
2537
+ pushViewerSession();
2065
2538
  });
2066
2539
  channel.on("peer_left", () => {
2067
- console.log("[host] Phone disconnected.");
2540
+ log("[host] Phone disconnected.");
2068
2541
  state.connected = false;
2069
2542
  terminal.detachStream();
2070
2543
  broadcast({ type: "peer_disconnected" });
2071
2544
  });
2072
2545
  channel.on("message", (data) => {
2073
2546
  if (isTerminalMessage(data)) {
2074
- console.log("[host] Terminal message from phone:", data.type);
2547
+ log("[host] Terminal message from phone:", data.type);
2075
2548
  terminal.handleMessage(data);
2076
2549
  return;
2077
2550
  }
2078
2551
  if (typeof data === "object" && data !== null && "type" in data && "content" in data) {
2079
2552
  const msg = data;
2080
2553
  if (msg.type === "chat" && typeof msg.content === "string") {
2081
- console.log(`[phone] ${msg.content}`);
2554
+ log(`[phone] ${msg.content}`);
2082
2555
  state.messages.push({ role: "user", content: msg.content, timestamp: Date.now() });
2083
2556
  broadcast({ type: "message", role: "user", content: msg.content });
2084
2557
  if (state.adapter) {
@@ -2087,12 +2560,12 @@ async function main() {
2087
2560
  }
2088
2561
  }
2089
2562
  });
2090
- channel.on("error", (err) => console.error("[host] Channel error:", err.message));
2563
+ channel.on("error", (err) => logError("[host] Channel error:", err.message));
2091
2564
  let shuttingDown = false;
2092
2565
  const shutdown = () => {
2093
2566
  if (shuttingDown) return;
2094
2567
  shuttingDown = true;
2095
- console.log("\n[host] Shutting down...");
2568
+ log("\n[host] Shutting down...");
2096
2569
  terminal.destroy();
2097
2570
  state.adapter?.destroy?.();
2098
2571
  try {
@@ -2106,6 +2579,6 @@ async function main() {
2106
2579
  process.on("SIGTERM", shutdown);
2107
2580
  }
2108
2581
  main().catch((err) => {
2109
- console.error("Fatal error:", err);
2582
+ logError("Fatal error:", err);
2110
2583
  process.exit(1);
2111
2584
  });