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 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 !== this.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
- console.log(`[cli-repl] Spawning PTY: ${executable} ${this.args.join(" ")}`);
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
- console.log(`[cli-repl] PTY exited (code ${exitCode})`);
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
- console.error("[config] Failed to save:", err.message);
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
- console.log(`[host] Fixed spawn-helper permissions: ${helperPath}`);
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
- 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 };
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
- console.error(`[host] PTY spawn failed: ${e.message} (code=${e.code ?? "none"}) file=${file} cwd=${cwd}`);
1215
+ logError(`[host] PTY spawn failed: ${e.message} (code=${e.code ?? "none"}) file=${file} cwd=${cwd}`);
1184
1216
  if (file !== "/bin/sh") {
1185
- console.error("[host] Retrying with /bin/sh...");
1217
+ logError("[host] Retrying with /bin/sh...");
1186
1218
  try {
1187
1219
  this.pty = spawn2("/bin/sh", [], spawnOpts);
1188
- console.log("[host] PTY fallback to /bin/sh succeeded");
1220
+ log("[host] PTY fallback to /bin/sh succeeded");
1189
1221
  } catch (err2) {
1190
- console.error("[host] PTY fallback also failed:", err2.message);
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) => console.error("[host] AI response error:", 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({ server, path: "/ws" });
1415
+ const wss = new WebSocketServer({ noServer: true });
1304
1416
  const uiClients = /* @__PURE__ */ new Set();
1305
- app.use(express.json());
1306
- app.get("/", (_req, res) => {
1307
- res.type("html").send(HOST_HTML);
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("/api/status", (_req, res) => {
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", (_req, res) => {
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", (_req, res) => {
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 = apiKey || process.env.ANTHROPIC_API_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 = apiKey || process.env.OPENAI_API_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 : typeof command === "string" && command.trim() ? command.trim() : presetInfo?.command;
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 (model) cfg.model = model;
1583
+ if (normalizedModel) cfg.model = normalizedModel;
1377
1584
  if (type === "cli") {
1378
- cfg.command = command;
1379
- cfg.preset = 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
- const { content } = req.body;
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.sessionToken) {
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
- console.error("[host] Invalid WebSocket message:", err);
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
- 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
- });
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:#1c1c1e;--tool-bg:#e5e5ea;--tool-hover:#d1d1d6;--msg-user:#d6d5f7;--msg-asst:#f2f2f7}}
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: '#1c1c1e', foreground: '#e6edf3', cursor: '#5856d6', cursorAccent: '#1c1c1e',
1607
- selectionBackground: 'rgba(88,86,214,0.30)',
1608
- black: '#1c1c1e', red: '#ff3b30', green: '#34c759', yellow: '#ff9f0a',
1609
- blue: '#5856d6', magenta: '#bf5af2', cyan: '#32d5d5', white: '#d1d1d6',
1610
- brightBlack: '#6e6e73', brightRed: '#ff6961', brightGreen: '#4cd964', brightYellow: '#ffd60a',
1611
- brightBlue: '#7c7aff', brightMagenta: '#da8aff', brightCyan: '#5ac8fa', brightWhite: '#f2f2f7',
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
- console.log("[host] Module loaded");
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 (CLI flag takes precedence).
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 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;
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
- 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");
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
- console.log("Transport: Ably (your key)");
2273
+ log("Transport: Ably (your key)");
1897
2274
  }
1898
2275
  } else {
1899
- console.log(`Transport: WebSocket (self-hosted relay at ${RELAY_URL})`);
2276
+ log(`Transport: WebSocket (self-hosted relay at ${RELAY_URL})`);
1900
2277
  }
1901
- const session = createSession(useAbly ? "ably" : RELAY_URL);
1902
- const displayCode = formatPairingCode(session.pairingCode);
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:${session.sessionToken}`;
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
- console.log(`[ably] Scoped token issued (TTL: ${Math.round(ABLY_TOKEN_TTL / 6e4)}min, channel: ${channelName})`);
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
- ...session.pairingData,
2304
+ relay: "ably",
2305
+ session: relaySessionToken,
2306
+ pub: toBase64(keyPair.publicKey),
2307
+ v: 1,
1920
2308
  transport: "ably",
1921
- token: tokenDetails.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:" + session.sessionToken));
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(session.sessionToken);
1941
- console.log("[host] Connected to relay, waiting for phone...");
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
- console.error(`[host] Unknown preset "${cliArgs.preset}". Available: ${CLI_PRESETS.map((p) => p.id).join(", ")}`);
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
- sessionToken: session.sessionToken,
1963
- ablyToken: useAbly ? pairingData.token : void 0,
2356
+ pairingSessionToken,
2357
+ relaySessionToken,
2358
+ ablyToken,
2359
+ ablyTokenExpiresAt,
1964
2360
  transport: useAbly ? "ably" : "ws"
1965
2361
  };
1966
- console.log(`[host] Terminal launch: ${terminalLaunch}`);
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
- console.log(`[host] CLI adapter: ${command} (${presetInfo?.mode ?? "oneshot"})`);
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
- console.log(`[host] Auto-configured: ${state.adapter.name} (${state.adapter.model})`);
2014
- console.log(` Loaded from ${getConfigPath()}`);
2409
+ log(`[host] Auto-configured: ${state.adapter.name} (${state.adapter.model})`);
2410
+ log(` Loaded from ${getConfigPath()}`);
2015
2411
  }
2016
2412
  } catch (err) {
2017
- console.error("[host] Auto-configure failed:", err.message);
2413
+ logError("[host] Auto-configure failed:", err.message);
2018
2414
  }
2019
2415
  }
2020
2416
  }
2021
2417
  const viewerDir = resolveViewerDir();
2022
2418
  if (viewerDir) {
2023
- console.log(`[host] Viewer files: ${viewerDir}`);
2419
+ log(`[host] Viewer files: ${viewerDir}`);
2024
2420
  } 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 });
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
- console.log(`[host] Web UI at ${localUrl}
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} ${localUrl}`);
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
- console.log("[host] Phone connected! Channel ready.");
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
- console.log("[host] Phone disconnected.");
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
- console.log("[host] Terminal message from phone:", data.type);
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
- console.log(`[phone] ${msg.content}`);
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) => console.error("[host] Channel error:", err.message));
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
- console.log("\n[host] Shutting down...");
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
- console.error("Fatal error:", err);
2526
+ logError("Fatal error:", err);
2110
2527
  process.exit(1);
2111
2528
  });