lunel-cli 0.1.84 → 0.1.86

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
@@ -2,13 +2,14 @@
2
2
  import { WebSocket } from "ws";
3
3
  import qrcode from "qrcode-terminal";
4
4
  import { createAiManager } from "./ai/index.js";
5
- import * as crypto from "crypto";
5
+ import { V2SessionTransport } from "./transport/v2.js";
6
6
  import Ignore from "ignore";
7
7
  const ignore = Ignore.default;
8
8
  import * as fs from "fs/promises";
9
9
  import * as fssync from "fs";
10
10
  import * as path from "path";
11
11
  import * as os from "os";
12
+ import { randomBytes } from "crypto";
12
13
  import { spawn, spawnSync, execSync, execFileSync } from "child_process";
13
14
  import { createServer, createConnection } from "net";
14
15
  import { createInterface } from "readline";
@@ -68,8 +69,7 @@ let currentSessionPassword = null;
68
69
  let currentPrimaryGateway = DEFAULT_PROXY_URL;
69
70
  let activeGatewayUrl = DEFAULT_PROXY_URL;
70
71
  let shuttingDown = false;
71
- let activeControlWs = null;
72
- let activeDataWs = null;
72
+ let activeV2Transport = null;
73
73
  function logWithTimestamp(scope, message, fields) {
74
74
  if (!DEBUG_MODE)
75
75
  return;
@@ -258,24 +258,6 @@ function samePortSet(a, b) {
258
258
  }
259
259
  return true;
260
260
  }
261
- function isProtocolRequest(value) {
262
- if (!value || typeof value !== "object")
263
- return false;
264
- const msg = value;
265
- return (msg.v === 1 &&
266
- typeof msg.id === "string" &&
267
- typeof msg.ns === "string" &&
268
- typeof msg.action === "string" &&
269
- typeof msg.payload === "object" &&
270
- msg.payload !== null &&
271
- typeof msg.ok === "undefined");
272
- }
273
- function isProtocolResponse(value) {
274
- if (!value || typeof value !== "object")
275
- return false;
276
- const msg = value;
277
- return msg.v === 1 && typeof msg.id === "string" && typeof msg.ok === "boolean";
278
- }
279
261
  // ============================================================================
280
262
  // Path Safety
281
263
  // ============================================================================
@@ -321,7 +303,7 @@ function assertSafePath(requestedPath) {
321
303
  }
322
304
  function generatePersistentSecret(length) {
323
305
  const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
324
- const bytes = crypto.randomBytes(length);
306
+ const bytes = randomBytes(length);
325
307
  let out = "";
326
308
  for (let i = 0; i < length; i++) {
327
309
  out += alphabet[bytes[i] % alphabet.length];
@@ -947,169 +929,12 @@ async function handleGitDiscard(payload) {
947
929
  }
948
930
  return {};
949
931
  }
950
- // ============================================================================
951
- // Terminal Handlers (delegates to Rust PTY binary)
952
- // ============================================================================
953
- let dataChannel = null;
954
- let e2eeKeyPair = null;
955
- let e2eeCliToAppKey = null; // AES-256-GCM key, CLI→App direction
956
- let e2eeAppToCliKey = null; // AES-256-GCM key, App→CLI direction
957
- let e2eeActive = false;
958
- let e2eeSentReady = false;
959
- let e2eeGotReady = false;
960
- // No outbound counter — we generate a full 96-bit random nonce per message.
961
- // AES-GCM nonce uniqueness is guaranteed by CSPRNG with negligible collision
962
- // probability (birthday bound: ~2^48 messages before 2^-32 collision risk).
963
- function e2eeReset() {
964
- e2eeKeyPair = null;
965
- e2eeCliToAppKey = null;
966
- e2eeAppToCliKey = null;
967
- e2eeActive = false;
968
- e2eeSentReady = false;
969
- e2eeGotReady = false;
970
- }
971
- function e2eeHandlePeerHello(peerPubkeyB64, dataWs) {
972
- const kp = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
973
- e2eeKeyPair = kp;
974
- const peerDer = Buffer.from(peerPubkeyB64, "base64url");
975
- const peerPubKey = crypto.createPublicKey({ key: peerDer, type: "spki", format: "der" });
976
- const sharedSecret = crypto.diffieHellman({ privateKey: kp.privateKey, publicKey: peerPubKey });
977
- const salt = Buffer.alloc(32);
978
- e2eeCliToAppKey = Buffer.from(crypto.hkdfSync("sha256", sharedSecret, salt, Buffer.from("lunel-cli-to-app"), 32));
979
- e2eeAppToCliKey = Buffer.from(crypto.hkdfSync("sha256", sharedSecret, salt, Buffer.from("lunel-app-to-cli"), 32));
980
- // Send our own pubkey so the app can derive the same shared secret
981
- const mySpki = kp.publicKey.export({ type: "spki", format: "der" });
982
- dataWs.send(JSON.stringify({ type: "e2ee_hello", pubkey: Buffer.from(mySpki).toString("base64url") }));
983
- dataWs.send(JSON.stringify({ type: "e2ee_secure_ready" }));
984
- e2eeSentReady = true;
985
- if (e2eeGotReady) {
986
- e2eeActive = true;
987
- debugLog("[e2ee] encryption active");
988
- }
989
- }
990
- function e2eeHandlePeerReady() {
991
- e2eeGotReady = true;
992
- if (e2eeSentReady) {
993
- e2eeActive = true;
994
- debugLog("[e2ee] encryption active");
995
- }
996
- }
997
- function e2eeEncrypt(payload) {
998
- if (!e2eeCliToAppKey)
999
- throw new Error("[e2ee] CLI→App key not ready");
1000
- // Full 96-bit random nonce — safe across reconnects with no counter state.
1001
- const nonce = crypto.randomBytes(12);
1002
- const plain = Buffer.from(JSON.stringify(payload));
1003
- const cipher = crypto.createCipheriv("aes-256-gcm", e2eeCliToAppKey, nonce);
1004
- const ct = Buffer.concat([cipher.update(plain), cipher.final()]);
1005
- const tag = cipher.getAuthTag();
1006
- // wire: nonce(12) || ct || tag(16) — consistent with WebCrypto AES-GCM format on app side
1007
- return Buffer.concat([nonce, ct, tag]).toString("base64url");
1008
- }
1009
- function e2eeDecrypt(enc) {
1010
- if (!e2eeAppToCliKey)
1011
- throw new Error("[e2ee] App→CLI key not ready");
1012
- const buf = Buffer.from(enc, "base64url");
1013
- const nonce = buf.subarray(0, 12);
1014
- const ct = buf.subarray(12, buf.length - 16);
1015
- const tag = buf.subarray(buf.length - 16);
1016
- const decipher = crypto.createDecipheriv("aes-256-gcm", e2eeAppToCliKey, nonce);
1017
- decipher.setAuthTag(tag);
1018
- return JSON.parse(Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf-8"));
1019
- }
1020
- // ============================================================================
1021
- // Replay buffer + backpressure
1022
- // ============================================================================
1023
- // Replay buffer for sequenced outbound data-channel messages
1024
- let outboundSeq = 0;
1025
- let lastSentSeq = 0; // highest seq actually flushed to the socket (not paused)
1026
- const REPLAY_BUFFER_MAX_MESSAGES = 500;
1027
- const REPLAY_BUFFER_MAX_BYTES = 10 * 1024 * 1024; // 10 MB
1028
- const replayBuffer = [];
1029
- let replayBufferBytes = 0;
1030
- // Data-channel backpressure
1031
- const DATA_CHANNEL_HIGH_WATER_BYTES = 1 * 1024 * 1024; // 1 MB — pause forwarding
1032
- const DATA_CHANNEL_LOW_WATER_BYTES = 256 * 1024; // 256 KB — resume forwarding
1033
- let dataChannelPaused = false;
1034
- let dataChannelDrainTimer = null;
1035
- function sendOnDataChannel(ws, msg) {
1036
- if (e2eeActive && e2eeCliToAppKey) {
1037
- const { payload, ...envelope } = msg;
1038
- ws.send(JSON.stringify({ ...envelope, enc: e2eeEncrypt(payload) }));
1039
- }
1040
- else {
1041
- ws.send(JSON.stringify(msg));
1042
- }
1043
- }
1044
- function sendSequenced(channel, msg) {
1045
- const seq = ++outboundSeq;
1046
- const msgWithSeq = { ...msg, seq };
1047
- const byteLen = Buffer.byteLength(JSON.stringify(msgWithSeq), "utf-8");
1048
- replayBuffer.push({ seq, msg: msgWithSeq, byteLen });
1049
- replayBufferBytes += byteLen;
1050
- while (replayBuffer.length > REPLAY_BUFFER_MAX_MESSAGES || replayBufferBytes > REPLAY_BUFFER_MAX_BYTES) {
1051
- const evicted = replayBuffer.shift();
1052
- if (evicted)
1053
- replayBufferBytes -= evicted.byteLen;
1054
- }
1055
- // Skip the actual send while backpressured — event is already in replay buffer
1056
- if (channel.readyState === WebSocket.OPEN && !dataChannelPaused) {
1057
- sendOnDataChannel(channel, msgWithSeq);
1058
- lastSentSeq = seq;
1059
- }
1060
- }
1061
- function resetReplayBuffer() {
1062
- outboundSeq = 0;
1063
- lastSentSeq = 0;
1064
- replayBuffer.length = 0;
1065
- replayBufferBytes = 0;
1066
- dataChannelPaused = false;
1067
- e2eeReset();
1068
- if (dataChannelDrainTimer) {
1069
- clearInterval(dataChannelDrainTimer);
1070
- dataChannelDrainTimer = null;
1071
- }
1072
- }
1073
- function resumeDataChannel() {
1074
- if (!dataChannelPaused)
1075
- return;
1076
- dataChannelPaused = false;
1077
- if (dataChannelDrainTimer) {
1078
- clearInterval(dataChannelDrainTimer);
1079
- dataChannelDrainTimer = null;
1080
- }
1081
- // Flush events that were buffered during the pause (re-encrypts with current key)
1082
- const toFlush = replayBuffer.filter((e) => e.seq > lastSentSeq);
1083
- if (dataChannel?.readyState === WebSocket.OPEN) {
1084
- for (const entry of toFlush) {
1085
- sendOnDataChannel(dataChannel, entry.msg);
1086
- lastSentSeq = entry.seq;
1087
- }
1088
- }
1089
- debugLog(`[backpressure] data channel resumed, flushed ${toFlush.length} buffered events`);
1090
- if (activeControlWs?.readyState === WebSocket.OPEN) {
1091
- const buffered = dataChannel?.bufferedAmount ?? 0;
1092
- activeControlWs.send(JSON.stringify({ type: "data_channel_resumed", bufferedBytes: buffered }));
1093
- }
1094
- }
1095
- function checkDataChannelBackpressure() {
1096
- if (!dataChannel || dataChannel.readyState !== WebSocket.OPEN)
1097
- return;
1098
- const buffered = dataChannel.bufferedAmount ?? 0;
1099
- if (!dataChannelPaused && buffered > DATA_CHANNEL_HIGH_WATER_BYTES) {
1100
- dataChannelPaused = true;
1101
- debugWarn(`[backpressure] data channel paused (${buffered} bytes buffered)`);
1102
- if (activeControlWs?.readyState === WebSocket.OPEN) {
1103
- activeControlWs.send(JSON.stringify({ type: "data_channel_paused", bufferedBytes: buffered }));
1104
- }
1105
- if (!dataChannelDrainTimer) {
1106
- dataChannelDrainTimer = setInterval(() => {
1107
- const current = dataChannel?.bufferedAmount ?? 0;
1108
- if (current < DATA_CHANNEL_LOW_WATER_BYTES || !dataChannel || dataChannel.readyState !== WebSocket.OPEN) {
1109
- resumeDataChannel();
1110
- }
1111
- }, 100);
1112
- }
932
+ function emitAppEvent(msg) {
933
+ if (activeV2Transport) {
934
+ void activeV2Transport.sendMessage(msg).catch((error) => {
935
+ if (DEBUG_MODE)
936
+ console.error("[transport:v2] failed to send event:", error instanceof Error ? error.message : String(error));
937
+ });
1113
938
  }
1114
939
  }
1115
940
  let ensurePtyBinaryPromise = null;
@@ -1298,45 +1123,41 @@ async function ensurePtyProcess() {
1298
1123
  }
1299
1124
  else if (event.event === "state") {
1300
1125
  // Forward screen state to app via data channel
1301
- if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
1302
- const msg = {
1303
- v: 1,
1304
- id: `evt-${Date.now()}`,
1305
- ns: "terminal",
1306
- action: "state",
1307
- payload: {
1308
- terminalId: event.id,
1309
- cells: event.cells,
1310
- cursorX: event.cursorX,
1311
- cursorY: event.cursorY,
1312
- cols: event.cols,
1313
- rows: event.rows,
1314
- cursorVisible: event.cursorVisible,
1315
- cursorStyle: event.cursorStyle,
1316
- appCursorKeys: event.appCursorKeys,
1317
- bracketedPaste: event.bracketedPaste,
1318
- mouseMode: event.mouseMode,
1319
- mouseEncoding: event.mouseEncoding,
1320
- reverseVideo: event.reverseVideo,
1321
- title: event.title,
1322
- scrollbackLength: event.scrollbackLength,
1323
- },
1324
- };
1325
- sendSequenced(dataChannel, msg);
1326
- }
1126
+ const msg = {
1127
+ v: 1,
1128
+ id: `evt-${Date.now()}`,
1129
+ ns: "terminal",
1130
+ action: "state",
1131
+ payload: {
1132
+ terminalId: event.id,
1133
+ cells: event.cells,
1134
+ cursorX: event.cursorX,
1135
+ cursorY: event.cursorY,
1136
+ cols: event.cols,
1137
+ rows: event.rows,
1138
+ cursorVisible: event.cursorVisible,
1139
+ cursorStyle: event.cursorStyle,
1140
+ appCursorKeys: event.appCursorKeys,
1141
+ bracketedPaste: event.bracketedPaste,
1142
+ mouseMode: event.mouseMode,
1143
+ mouseEncoding: event.mouseEncoding,
1144
+ reverseVideo: event.reverseVideo,
1145
+ title: event.title,
1146
+ scrollbackLength: event.scrollbackLength,
1147
+ },
1148
+ };
1149
+ emitAppEvent(msg);
1327
1150
  }
1328
1151
  else if (event.event === "exit") {
1329
1152
  terminals.delete(event.id);
1330
- if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
1331
- const msg = {
1332
- v: 1,
1333
- id: `evt-${Date.now()}`,
1334
- ns: "terminal",
1335
- action: "exit",
1336
- payload: { terminalId: event.id, code: event.code },
1337
- };
1338
- sendSequenced(dataChannel, msg);
1339
- }
1153
+ const msg = {
1154
+ v: 1,
1155
+ id: `evt-${Date.now()}`,
1156
+ ns: "terminal",
1157
+ action: "exit",
1158
+ payload: { terminalId: event.id, code: event.code },
1159
+ };
1160
+ emitAppEvent(msg);
1340
1161
  }
1341
1162
  else if (event.event === "error") {
1342
1163
  console.error(`[pty] Error for ${event.id}: ${event.message}`);
@@ -1478,30 +1299,26 @@ function handleProcessesSpawn(payload) {
1478
1299
  const text = data.toString();
1479
1300
  managedProc.output.push(text);
1480
1301
  processOutputBuffers.set(channel, (processOutputBuffers.get(channel) || "") + text);
1481
- if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
1482
- const msg = {
1483
- v: 1,
1484
- id: `evt-${Date.now()}`,
1485
- ns: "processes",
1486
- action: "output",
1487
- payload: { pid, channel, stream, data: text },
1488
- };
1489
- sendSequenced(dataChannel, msg);
1490
- }
1302
+ const msg = {
1303
+ v: 1,
1304
+ id: `evt-${Date.now()}`,
1305
+ ns: "processes",
1306
+ action: "output",
1307
+ payload: { pid, channel, stream, data: text },
1308
+ };
1309
+ emitAppEvent(msg);
1491
1310
  };
1492
1311
  proc.stdout?.on("data", sendOutput("stdout"));
1493
1312
  proc.stderr?.on("data", sendOutput("stderr"));
1494
1313
  proc.on("close", (code, signal) => {
1495
- if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
1496
- const msg = {
1497
- v: 1,
1498
- id: `evt-${Date.now()}`,
1499
- ns: "processes",
1500
- action: "exit",
1501
- payload: { pid, channel, code, signal },
1502
- };
1503
- dataChannel.send(JSON.stringify(msg));
1504
- }
1314
+ const msg = {
1315
+ v: 1,
1316
+ id: `evt-${Date.now()}`,
1317
+ ns: "processes",
1318
+ action: "exit",
1319
+ payload: { pid, channel, code, signal },
1320
+ };
1321
+ emitAppEvent(msg);
1505
1322
  });
1506
1323
  return { pid, channel };
1507
1324
  }
@@ -1953,10 +1770,10 @@ async function scanDevPorts() {
1953
1770
  await Promise.all(checks);
1954
1771
  return openPorts.sort((a, b) => a - b);
1955
1772
  }
1956
- async function publishDiscoveredPorts(ws, force = false) {
1773
+ async function publishDiscoveredPorts(force = false) {
1957
1774
  if (portScanInFlight)
1958
1775
  return;
1959
- if (ws.readyState !== WebSocket.OPEN)
1776
+ if (!activeV2Transport)
1960
1777
  return;
1961
1778
  portScanInFlight = true;
1962
1779
  try {
@@ -1965,13 +1782,13 @@ async function publishDiscoveredPorts(ws, force = false) {
1965
1782
  return;
1966
1783
  }
1967
1784
  lastDiscoveredPorts = openPorts;
1968
- ws.send(JSON.stringify({
1785
+ emitAppEvent({
1969
1786
  v: 1,
1970
1787
  id: `evt-${Date.now()}`,
1971
1788
  ns: "proxy",
1972
1789
  action: "ports_discovered",
1973
1790
  payload: { ports: openPorts },
1974
- }));
1791
+ });
1975
1792
  debugLog(`[proxy] ports updated (${openPorts.length}): ${openPorts.join(", ") || "-"}`);
1976
1793
  }
1977
1794
  catch (err) {
@@ -1988,11 +1805,11 @@ function stopPortSync() {
1988
1805
  }
1989
1806
  portScanInFlight = false;
1990
1807
  }
1991
- function startPortSync(ws) {
1808
+ function startPortSync() {
1992
1809
  stopPortSync();
1993
- void publishDiscoveredPorts(ws, true);
1810
+ void publishDiscoveredPorts(true);
1994
1811
  portSyncTimer = setInterval(() => {
1995
- void publishDiscoveredPorts(ws, false);
1812
+ void publishDiscoveredPorts(false);
1996
1813
  }, PORT_SYNC_INTERVAL_MS);
1997
1814
  }
1998
1815
  async function handleProxyConnect(payload) {
@@ -2576,23 +2393,6 @@ async function processMessage(message) {
2576
2393
  };
2577
2394
  }
2578
2395
  }
2579
- function sendResponseOnData(response, dataWs) {
2580
- const pathValue = typeof response.payload?.path === "string" ? response.payload.path : null;
2581
- logWithTimestamp("router", "sending response on data", {
2582
- id: response.id,
2583
- ns: response.ns,
2584
- action: response.action,
2585
- path: pathValue,
2586
- ok: response.ok,
2587
- });
2588
- if (e2eeActive && e2eeCliToAppKey) {
2589
- const { payload, ...envelope } = response;
2590
- dataWs.send(JSON.stringify({ ...envelope, enc: e2eeEncrypt(payload) }));
2591
- }
2592
- else {
2593
- dataWs.send(JSON.stringify(response));
2594
- }
2595
- }
2596
2396
  function normalizeGatewayUrl(input) {
2597
2397
  const raw = input.trim();
2598
2398
  if (!raw) {
@@ -2726,25 +2526,13 @@ function displayQR(code) {
2726
2526
  console.log(" Press Ctrl+C to exit.\n");
2727
2527
  });
2728
2528
  }
2729
- function buildWsUrl(gatewayUrl, role, channel) {
2730
- const wsBase = gatewayUrl.replace(/^https:/, "wss:");
2731
- if (!wsBase.startsWith("wss://")) {
2732
- throw new Error("Gateway URL must use https://");
2733
- }
2734
- const query = new URLSearchParams();
2735
- if (currentSessionPassword) {
2736
- query.set("password", currentSessionPassword);
2737
- }
2738
- else {
2739
- throw new Error("missing password for websocket connect");
2740
- }
2741
- return `${wsBase}/v1/ws/${role}/${channel}?${query.toString()}`;
2742
- }
2743
2529
  function gracefulShutdown() {
2744
2530
  shuttingDown = true;
2745
2531
  console.log("\nShutting down...");
2746
2532
  void aiManager?.destroy();
2747
2533
  stopPortSync();
2534
+ activeV2Transport?.close();
2535
+ activeV2Transport = null;
2748
2536
  if (ptyProcess) {
2749
2537
  ptyProcess.kill();
2750
2538
  ptyProcess = null;
@@ -2756,208 +2544,72 @@ function gracefulShutdown() {
2756
2544
  processes.clear();
2757
2545
  processOutputBuffers.clear();
2758
2546
  cleanupAllTunnels();
2759
- if (activeControlWs && (activeControlWs.readyState === WebSocket.OPEN || activeControlWs.readyState === WebSocket.CONNECTING)) {
2760
- activeControlWs.close();
2761
- }
2762
- if (activeDataWs && (activeDataWs.readyState === WebSocket.OPEN || activeDataWs.readyState === WebSocket.CONNECTING)) {
2763
- activeDataWs.close();
2764
- }
2765
2547
  process.exit(0);
2766
2548
  }
2767
- async function connectWebSocket() {
2549
+ async function connectWebSocketV2() {
2768
2550
  const gatewayUrl = currentPrimaryGateway;
2769
- await new Promise((resolve, reject) => {
2770
- activeGatewayUrl = gatewayUrl;
2771
- const controlUrl = buildWsUrl(gatewayUrl, "cli", "control");
2772
- const dataUrl = buildWsUrl(gatewayUrl, "cli", "data");
2773
- console.log(`Connecting to gateway ${gatewayUrl}...`);
2774
- const controlWs = new WebSocket(controlUrl);
2775
- const dataWs = new WebSocket(dataUrl);
2776
- activeControlWs = controlWs;
2777
- activeDataWs = dataWs;
2778
- dataChannel = dataWs;
2779
- let controlConnected = false;
2780
- let dataConnected = false;
2781
- let settled = false;
2782
- let closeHandled = false;
2783
- let closeReason = "";
2784
- const failConnection = (reason) => {
2785
- if (settled)
2786
- return;
2787
- settled = true;
2788
- reject(new Error(reason));
2789
- };
2790
- const checkFullyConnected = () => {
2791
- if (controlConnected && dataConnected && !settled) {
2792
- settled = true;
2793
- console.log("Connected to gateway (control + data channels).\n");
2794
- resolve();
2795
- }
2796
- };
2797
- const handleClose = (reason) => {
2798
- if (closeHandled || shuttingDown)
2799
- return;
2800
- closeHandled = true;
2801
- closeReason = reason;
2802
- stopPortSync();
2803
- cleanupAllTunnels();
2804
- setTimeout(() => {
2805
- if (shuttingDown)
2806
- return;
2807
- void handleConnectionDrop(closeReason);
2808
- }, 50);
2809
- };
2810
- controlWs.on("open", () => {
2811
- controlConnected = true;
2812
- checkFullyConnected();
2813
- });
2814
- controlWs.on("message", async (data) => {
2815
- try {
2816
- const message = JSON.parse(data.toString());
2817
- if ("type" in message) {
2818
- if (message.type === "connected")
2819
- return;
2820
- if (message.type === "peer_connected") {
2821
- console.log("App connected!\n");
2822
- startPortSync(controlWs);
2823
- return;
2824
- }
2825
- if (message.type === "peer_disconnected") {
2826
- console.log("App disconnected. Waiting for reconnect window.\n");
2827
- stopPortSync();
2828
- return;
2829
- }
2830
- if (message.type === "app_disconnected") {
2831
- if (message.reconnectDeadline) {
2832
- console.log(`[session] app disconnected, waiting until ${new Date(message.reconnectDeadline).toISOString()}`);
2833
- }
2834
- return;
2835
- }
2836
- if (message.type === "close_connection") {
2837
- const reason = message.reason || "expired";
2838
- console.log(`[session] closed by gateway: ${reason}`);
2839
- if (reason === "session ended from app") {
2840
- console.log("[session] Run `npx lunel-cli` again and scan the new QR code to reconnect.");
2841
- }
2842
- gracefulShutdown();
2843
- return;
2844
- }
2845
- }
2846
- if (isProtocolResponse(message)) {
2847
- // Ignore server/app responses forwarded over WS; CLI only processes requests.
2848
- return;
2849
- }
2850
- if (isProtocolRequest(message)) {
2851
- const response = await processMessage(message);
2852
- sendResponseOnData(response, dataWs);
2853
- return;
2854
- }
2855
- debugWarn("[router] Ignoring non-request control frame");
2856
- }
2857
- catch (error) {
2858
- if (DEBUG_MODE)
2859
- console.error("Error processing control message:", error);
2860
- }
2861
- });
2862
- controlWs.on("close", (code, reason) => {
2863
- if (!settled) {
2864
- failConnection(`control close before ready (${code}: ${reason.toString()})`);
2865
- return;
2866
- }
2867
- handleClose(`control closed (${code}: ${reason.toString()})`);
2868
- });
2869
- controlWs.on("error", (error) => {
2870
- if (!settled) {
2871
- failConnection(`control ws error: ${error.message}`);
2872
- return;
2873
- }
2874
- if (DEBUG_MODE)
2875
- console.error("Control WebSocket error:", error.message);
2876
- });
2877
- dataWs.on("open", () => {
2878
- dataConnected = true;
2879
- checkFullyConnected();
2880
- });
2881
- dataWs.on("message", async (data) => {
2882
- try {
2883
- const raw = JSON.parse(data.toString());
2884
- if (raw.type === "connected")
2551
+ if (!currentSessionPassword) {
2552
+ throw new Error("missing password for websocket connect");
2553
+ }
2554
+ console.log(`Connecting to gateway ${gatewayUrl}...`);
2555
+ activeGatewayUrl = gatewayUrl;
2556
+ const transport = new V2SessionTransport({
2557
+ gatewayUrl,
2558
+ password: currentSessionPassword,
2559
+ role: "cli",
2560
+ debugLog: DEBUG_MODE ? debugLog : undefined,
2561
+ handlers: {
2562
+ onSystemMessage: async (message) => {
2563
+ if (message.type === "connected")
2885
2564
  return;
2886
- // E2EE handshake messages (always plaintext)
2887
- if (raw.type === "e2ee_hello" && typeof raw.pubkey === "string") {
2888
- e2eeHandlePeerHello(raw.pubkey, dataWs);
2565
+ if (message.type === "peer_connected") {
2566
+ console.log("App connected!\n");
2567
+ startPortSync();
2889
2568
  return;
2890
2569
  }
2891
- if (raw.type === "e2ee_secure_ready") {
2892
- e2eeHandlePeerReady();
2570
+ if (message.type === "peer_disconnected") {
2571
+ console.log("App disconnected. Waiting for reconnect window.\n");
2572
+ stopPortSync();
2893
2573
  return;
2894
2574
  }
2895
- // Reconnect request: reset E2EE so fresh handshake happens, then replay
2896
- if (raw.ns === "system" && raw.action === "reconnect") {
2897
- e2eeReset();
2898
- const lastSeq = Number(raw.payload?.lastSeq ?? 0);
2899
- const toReplay = replayBuffer.filter((e) => e.seq > lastSeq);
2900
- debugLog(`[replay] replaying ${toReplay.length} messages after seq ${lastSeq}`);
2901
- // Replay without encryption — E2EE handshake hasn't completed yet
2902
- for (const entry of toReplay)
2903
- dataWs.send(JSON.stringify(entry.msg));
2575
+ if (message.type === "app_disconnected") {
2576
+ if (message.reconnectDeadline) {
2577
+ console.log(`[session] app disconnected, waiting until ${new Date(message.reconnectDeadline).toISOString()}`);
2578
+ }
2904
2579
  return;
2905
2580
  }
2906
- // Decrypt payload if E2EE is active
2907
- let message = raw;
2908
- if (e2eeActive && typeof raw.enc === "string") {
2909
- try {
2910
- message = { ...raw, payload: e2eeDecrypt(raw.enc) };
2911
- delete message.enc;
2581
+ if (message.type === "close_connection") {
2582
+ const reason = message.reason || "expired";
2583
+ console.log(`[session] closed by gateway: ${reason}`);
2584
+ if (reason === "session ended from app") {
2585
+ console.log("[session] Run `npx lunel-cli` again and scan the new QR code to reconnect.");
2912
2586
  }
2913
- catch (decErr) {
2914
- console.error("[e2ee] decryption failed:", decErr.message);
2915
- return;
2916
- }
2917
- }
2918
- if (isProtocolResponse(message)) {
2919
- // Ignore server/app responses forwarded over WS; CLI only processes requests.
2920
- return;
2587
+ gracefulShutdown();
2921
2588
  }
2922
- if (isProtocolRequest(message)) {
2923
- const response = await processMessage(message);
2924
- sendResponseOnData(response, dataWs);
2589
+ },
2590
+ onProtocolRequest: async (message) => {
2591
+ return await processMessage(message);
2592
+ },
2593
+ onProtocolResponse: async () => {
2594
+ // CLI does not currently await app responses outside request/reply routing.
2595
+ },
2596
+ onClose: (reason) => {
2597
+ if (shuttingDown)
2925
2598
  return;
2926
- }
2927
- debugWarn("[router] Ignoring non-request data frame");
2928
- }
2929
- catch (error) {
2930
- if (DEBUG_MODE)
2931
- console.error("Error processing data message:", error);
2932
- }
2933
- });
2934
- dataWs.on("close", (code, reason) => {
2935
- // Reset backpressure state so reconnect starts fresh
2936
- dataChannelPaused = false;
2937
- if (dataChannelDrainTimer) {
2938
- clearInterval(dataChannelDrainTimer);
2939
- dataChannelDrainTimer = null;
2940
- }
2941
- if (!settled) {
2942
- failConnection(`data close before ready (${code}: ${reason.toString()})`);
2943
- return;
2944
- }
2945
- handleClose(`data closed (${code}: ${reason.toString()})`);
2946
- });
2947
- dataWs.on("error", (error) => {
2948
- if (!settled) {
2949
- failConnection(`data ws error: ${error.message}`);
2950
- return;
2951
- }
2952
- if (DEBUG_MODE)
2953
- console.error("Data WebSocket error:", error.message);
2954
- });
2955
- setTimeout(() => {
2956
- if (!settled) {
2957
- failConnection("connection timeout");
2958
- }
2959
- }, 10000);
2599
+ stopPortSync();
2600
+ cleanupAllTunnels();
2601
+ activeV2Transport = null;
2602
+ setTimeout(() => {
2603
+ if (shuttingDown)
2604
+ return;
2605
+ void handleConnectionDrop(reason);
2606
+ }, 50);
2607
+ },
2608
+ },
2960
2609
  });
2610
+ activeV2Transport = transport;
2611
+ await transport.connect();
2612
+ console.log("Connected to gateway (single secure session).\n");
2961
2613
  }
2962
2614
  async function handleConnectionDrop(reason) {
2963
2615
  if (shuttingDown)
@@ -2975,7 +2627,7 @@ async function handleConnectionDrop(reason) {
2975
2627
  const delayMs = Math.round(base * (0.8 + Math.random() * 0.4));
2976
2628
  try {
2977
2629
  currentPrimaryGateway = await getAssignedProxyUrl(currentSessionPassword);
2978
- await connectWebSocket();
2630
+ await connectWebSocketV2();
2979
2631
  debugLog(`[reconnect] connected via ${activeGatewayUrl}`);
2980
2632
  return;
2981
2633
  }
@@ -3007,16 +2659,13 @@ async function main() {
3007
2659
  aiManager = await createAiManager();
3008
2660
  // Wire provider events → mobile app data channel, tagged with backend name.
3009
2661
  aiManager.subscribe((backend, event) => {
3010
- if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
3011
- sendSequenced(dataChannel, {
3012
- v: 1,
3013
- id: `evt-${Date.now()}`,
3014
- ns: "ai",
3015
- action: "event",
3016
- payload: { ...event, backend },
3017
- });
3018
- checkDataChannelBackpressure();
3019
- }
2662
+ emitAppEvent({
2663
+ v: 1,
2664
+ id: `evt-${Date.now()}`,
2665
+ ns: "ai",
2666
+ action: "event",
2667
+ payload: { ...event, backend },
2668
+ });
3020
2669
  });
3021
2670
  let sessionCodeToUse = null;
3022
2671
  let sessionPasswordToUse;
@@ -3046,12 +2695,11 @@ async function main() {
3046
2695
  sessionPasswordToUse = assembled.password;
3047
2696
  await saveSessionForRoot(sessionCodeToUse, sessionPasswordToUse);
3048
2697
  }
3049
- resetReplayBuffer();
3050
2698
  currentSessionCode = sessionCodeToUse;
3051
2699
  currentSessionPassword = sessionPasswordToUse;
3052
2700
  currentPrimaryGateway = await getAssignedProxyUrl(sessionPasswordToUse);
3053
2701
  activeGatewayUrl = currentPrimaryGateway;
3054
- await connectWebSocket();
2702
+ await connectWebSocketV2();
3055
2703
  }
3056
2704
  catch (error) {
3057
2705
  const message = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,56 @@
1
+ export interface Message {
2
+ v: 1;
3
+ id: string;
4
+ ns: string;
5
+ action: string;
6
+ payload: Record<string, unknown>;
7
+ }
8
+ export interface Response {
9
+ v: 1;
10
+ id: string;
11
+ ns: string;
12
+ action: string;
13
+ ok: boolean;
14
+ payload: Record<string, unknown>;
15
+ error?: {
16
+ code: string;
17
+ message: string;
18
+ };
19
+ }
20
+ export interface SystemMessage {
21
+ type: "connected" | "peer_connected" | "peer_disconnected" | "error" | "app_disconnected" | "close_connection" | "e2ee_hello" | "e2ee_secure_ready";
22
+ role?: string;
23
+ channel?: string;
24
+ peer?: string;
25
+ pubkey?: string;
26
+ reconnectDeadline?: number;
27
+ reason?: string;
28
+ payload?: Record<string, unknown>;
29
+ }
30
+ export type V2HandshakeFrame = {
31
+ t: "lunel_v2";
32
+ kind: "client_hello";
33
+ pubkey: string;
34
+ } | {
35
+ t: "lunel_v2";
36
+ kind: "server_hello";
37
+ pubkey: string;
38
+ header: string;
39
+ } | {
40
+ t: "lunel_v2";
41
+ kind: "client_ready";
42
+ header: string;
43
+ };
44
+ export declare const V2_BINARY_MAGIC_0 = 76;
45
+ export declare const V2_BINARY_MAGIC_1 = 50;
46
+ export declare const V2_FRAME_ENCRYPTED_MESSAGE = 1;
47
+ export declare function isProtocolRequest(value: unknown): value is Message;
48
+ export declare function isProtocolResponse(value: unknown): value is Response;
49
+ export declare function isV2HandshakeFrame(value: unknown): value is V2HandshakeFrame;
50
+ export declare function encodeV2EncryptedFrame(payload: Uint8Array): Uint8Array;
51
+ export declare function decodeV2BinaryFrame(data: Uint8Array): {
52
+ type: number;
53
+ payload: Uint8Array;
54
+ } | null;
55
+ export declare function buildSessionV1WsUrl(gatewayUrl: string, role: "cli" | "app", channel: "control" | "data", password: string): string;
56
+ export declare function buildSessionV2WsUrl(gatewayUrl: string, role: "cli" | "app", password: string): string;
@@ -0,0 +1,69 @@
1
+ export const V2_BINARY_MAGIC_0 = 0x4c; // L
2
+ export const V2_BINARY_MAGIC_1 = 0x32; // 2
3
+ export const V2_FRAME_ENCRYPTED_MESSAGE = 0x01;
4
+ export function isProtocolRequest(value) {
5
+ if (!value || typeof value !== "object")
6
+ return false;
7
+ const msg = value;
8
+ return (msg.v === 1 &&
9
+ typeof msg.id === "string" &&
10
+ typeof msg.ns === "string" &&
11
+ typeof msg.action === "string" &&
12
+ typeof msg.payload === "object" &&
13
+ msg.payload !== null &&
14
+ typeof msg.ok === "undefined");
15
+ }
16
+ export function isProtocolResponse(value) {
17
+ if (!value || typeof value !== "object")
18
+ return false;
19
+ const msg = value;
20
+ return msg.v === 1 && typeof msg.id === "string" && typeof msg.ok === "boolean";
21
+ }
22
+ export function isV2HandshakeFrame(value) {
23
+ if (!value || typeof value !== "object")
24
+ return false;
25
+ const frame = value;
26
+ if (frame.t !== "lunel_v2" || typeof frame.kind !== "string")
27
+ return false;
28
+ if (frame.kind === "client_hello")
29
+ return typeof frame.pubkey === "string";
30
+ if (frame.kind === "server_hello")
31
+ return typeof frame.pubkey === "string" && typeof frame.header === "string";
32
+ if (frame.kind === "client_ready")
33
+ return typeof frame.header === "string";
34
+ return false;
35
+ }
36
+ export function encodeV2EncryptedFrame(payload) {
37
+ const frame = new Uint8Array(payload.length + 3);
38
+ frame[0] = V2_BINARY_MAGIC_0;
39
+ frame[1] = V2_BINARY_MAGIC_1;
40
+ frame[2] = V2_FRAME_ENCRYPTED_MESSAGE;
41
+ frame.set(payload, 3);
42
+ return frame;
43
+ }
44
+ export function decodeV2BinaryFrame(data) {
45
+ if (data.length < 3)
46
+ return null;
47
+ if (data[0] !== V2_BINARY_MAGIC_0 || data[1] !== V2_BINARY_MAGIC_1)
48
+ return null;
49
+ return {
50
+ type: data[2],
51
+ payload: data.subarray(3),
52
+ };
53
+ }
54
+ export function buildSessionV1WsUrl(gatewayUrl, role, channel, password) {
55
+ const wsBase = gatewayUrl.replace(/^https:/, "wss:");
56
+ if (!wsBase.startsWith("wss://")) {
57
+ throw new Error("Gateway URL must use https://");
58
+ }
59
+ const query = new URLSearchParams({ password });
60
+ return `${wsBase}/v1/ws/${role}/${channel}?${query.toString()}`;
61
+ }
62
+ export function buildSessionV2WsUrl(gatewayUrl, role, password) {
63
+ const wsBase = gatewayUrl.replace(/^https:/, "wss:");
64
+ if (!wsBase.startsWith("wss://")) {
65
+ throw new Error("Gateway URL must use https://");
66
+ }
67
+ const query = new URLSearchParams({ password });
68
+ return `${wsBase}/v2/ws/${role}?${query.toString()}`;
69
+ }
@@ -0,0 +1,41 @@
1
+ import type { Message, Response, SystemMessage } from "./protocol.js";
2
+ export interface V2TransportHandlers {
3
+ onSystemMessage: (message: SystemMessage) => Promise<void> | void;
4
+ onProtocolRequest: (message: Message) => Promise<Response>;
5
+ onProtocolResponse?: (message: Response) => Promise<void> | void;
6
+ onClose: (reason: string) => void;
7
+ }
8
+ export interface V2TransportOptions {
9
+ gatewayUrl: string;
10
+ password: string;
11
+ role: "cli" | "app";
12
+ handlers: V2TransportHandlers;
13
+ debugLog?: (message: string, ...args: unknown[]) => void;
14
+ }
15
+ export declare class V2SessionTransport {
16
+ private readonly options;
17
+ private ws;
18
+ private closed;
19
+ private state;
20
+ private keyPair;
21
+ private sessionKeys;
22
+ private pushState;
23
+ private pullState;
24
+ private secureReadyResolve;
25
+ private secureReadyReject;
26
+ private secureReadyPromise;
27
+ constructor(options: V2TransportOptions);
28
+ connect(): Promise<void>;
29
+ sendMessage(message: Message): Promise<void>;
30
+ sendResponse(response: Response): Promise<void>;
31
+ close(): void;
32
+ private handleMessage;
33
+ private maybeStartHandshake;
34
+ private handleHandshakeFrame;
35
+ private encryptMessage;
36
+ private ensureKeyPair;
37
+ private sendJsonFrame;
38
+ private sendBinaryFrame;
39
+ private markSecure;
40
+ private failSecure;
41
+ }
@@ -0,0 +1,275 @@
1
+ import { WebSocket } from "ws";
2
+ import { createRequire } from "module";
3
+ import { V2_FRAME_ENCRYPTED_MESSAGE, buildSessionV2WsUrl, decodeV2BinaryFrame, encodeV2EncryptedFrame, isProtocolRequest, isProtocolResponse, isV2HandshakeFrame, } from "./protocol.js";
4
+ const require = createRequire(import.meta.url);
5
+ const sodium = require("libsodium-wrappers");
6
+ function toUint8Array(data) {
7
+ if (data instanceof Uint8Array)
8
+ return data;
9
+ if (Array.isArray(data))
10
+ return new Uint8Array(Buffer.concat(data.map((chunk) => Buffer.from(chunk))));
11
+ return new Uint8Array(data);
12
+ }
13
+ export class V2SessionTransport {
14
+ options;
15
+ ws = null;
16
+ closed = false;
17
+ state = "idle";
18
+ keyPair = null;
19
+ sessionKeys = null;
20
+ pushState = null;
21
+ pullState = null;
22
+ secureReadyResolve = null;
23
+ secureReadyReject = null;
24
+ secureReadyPromise = null;
25
+ constructor(options) {
26
+ this.options = options;
27
+ }
28
+ async connect() {
29
+ if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
30
+ if (this.secureReadyPromise) {
31
+ return await this.secureReadyPromise;
32
+ }
33
+ return;
34
+ }
35
+ await sodium.ready;
36
+ this.secureReadyPromise = new Promise((resolve, reject) => {
37
+ this.secureReadyResolve = resolve;
38
+ this.secureReadyReject = reject;
39
+ });
40
+ const wsUrl = buildSessionV2WsUrl(this.options.gatewayUrl, this.options.role, this.options.password);
41
+ await new Promise((resolve, reject) => {
42
+ const ws = new WebSocket(wsUrl);
43
+ let opened = false;
44
+ this.ws = ws;
45
+ this.closed = false;
46
+ this.state = "connecting";
47
+ ws.on("open", () => {
48
+ opened = true;
49
+ this.state = "open";
50
+ resolve();
51
+ });
52
+ ws.on("message", async (data) => {
53
+ try {
54
+ await this.handleMessage(data);
55
+ }
56
+ catch (error) {
57
+ this.options.debugLog?.("[transport:v2] message handling failed", error);
58
+ this.failSecure(new Error(error instanceof Error ? error.message : String(error)));
59
+ this.close();
60
+ }
61
+ });
62
+ ws.on("close", (code, reason) => {
63
+ this.ws = null;
64
+ this.state = "closed";
65
+ if (!opened) {
66
+ reject(new Error(`v2 socket closed during setup (${code}: ${reason.toString()})`));
67
+ return;
68
+ }
69
+ if (!this.closed) {
70
+ this.closed = true;
71
+ this.failSecure(new Error(`v2 socket closed (${code}: ${reason.toString()})`));
72
+ this.options.handlers.onClose(`v2 socket closed (${code}: ${reason.toString()})`);
73
+ }
74
+ });
75
+ ws.on("error", (error) => {
76
+ if (!opened) {
77
+ reject(new Error(`v2 socket error: ${error.message}`));
78
+ return;
79
+ }
80
+ this.options.debugLog?.("[transport:v2] websocket error", error.message);
81
+ });
82
+ });
83
+ if (!this.secureReadyPromise) {
84
+ throw new Error("secure readiness promise missing");
85
+ }
86
+ await this.secureReadyPromise;
87
+ }
88
+ async sendMessage(message) {
89
+ const ciphertext = await this.encryptMessage(message);
90
+ this.sendBinaryFrame(ciphertext);
91
+ }
92
+ async sendResponse(response) {
93
+ const ciphertext = await this.encryptMessage(response);
94
+ this.sendBinaryFrame(ciphertext);
95
+ }
96
+ close() {
97
+ this.closed = true;
98
+ this.state = "closed";
99
+ if (!this.ws)
100
+ return;
101
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
102
+ this.ws.close();
103
+ }
104
+ this.ws = null;
105
+ }
106
+ async handleMessage(data) {
107
+ if (typeof data === "string") {
108
+ const raw = JSON.parse(data);
109
+ if ("type" in raw) {
110
+ await this.options.handlers.onSystemMessage(raw);
111
+ if (raw.type === "peer_connected") {
112
+ await this.maybeStartHandshake();
113
+ }
114
+ return;
115
+ }
116
+ if (isV2HandshakeFrame(raw)) {
117
+ await this.handleHandshakeFrame(raw);
118
+ return;
119
+ }
120
+ if (this.state !== "secure") {
121
+ throw new Error("received plaintext app message before secure transport");
122
+ }
123
+ if (isProtocolResponse(raw)) {
124
+ await this.options.handlers.onProtocolResponse?.(raw);
125
+ return;
126
+ }
127
+ if (isProtocolRequest(raw)) {
128
+ const response = await this.options.handlers.onProtocolRequest(raw);
129
+ await this.sendResponse(response);
130
+ return;
131
+ }
132
+ return;
133
+ }
134
+ const bytes = toUint8Array(data);
135
+ const frame = decodeV2BinaryFrame(bytes);
136
+ if (!frame) {
137
+ throw new Error("invalid binary v2 frame");
138
+ }
139
+ if (frame.type !== V2_FRAME_ENCRYPTED_MESSAGE) {
140
+ throw new Error(`unsupported v2 frame type ${frame.type}`);
141
+ }
142
+ if (this.state !== "secure" || !this.pullState) {
143
+ throw new Error("received encrypted frame before secure transport");
144
+ }
145
+ const pulled = sodium.crypto_secretstream_xchacha20poly1305_pull(this.pullState, frame.payload);
146
+ if (!pulled.message) {
147
+ throw new Error("failed to decrypt v2 frame");
148
+ }
149
+ const parsed = JSON.parse(sodium.to_string(pulled.message));
150
+ if (isProtocolResponse(parsed)) {
151
+ await this.options.handlers.onProtocolResponse?.(parsed);
152
+ return;
153
+ }
154
+ if (isProtocolRequest(parsed)) {
155
+ const response = await this.options.handlers.onProtocolRequest(parsed);
156
+ await this.sendResponse(response);
157
+ return;
158
+ }
159
+ throw new Error("invalid decrypted protocol message");
160
+ }
161
+ async maybeStartHandshake() {
162
+ if (this.state === "secure" || this.state === "handshaking")
163
+ return;
164
+ if (this.options.role !== "app")
165
+ return;
166
+ this.state = "handshaking";
167
+ const keyPair = this.ensureKeyPair();
168
+ const hello = {
169
+ t: "lunel_v2",
170
+ kind: "client_hello",
171
+ pubkey: sodium.to_base64(keyPair.publicKey, sodium.base64_variants.URLSAFE_NO_PADDING),
172
+ };
173
+ this.sendJsonFrame(hello);
174
+ }
175
+ async handleHandshakeFrame(frame) {
176
+ this.state = "handshaking";
177
+ if (frame.kind === "client_hello") {
178
+ if (this.options.role !== "cli") {
179
+ throw new Error("unexpected client_hello on app transport");
180
+ }
181
+ const clientPublicKey = sodium.from_base64(frame.pubkey, sodium.base64_variants.URLSAFE_NO_PADDING);
182
+ const keyPair = this.ensureKeyPair();
183
+ const keys = sodium.crypto_kx_server_session_keys(keyPair.publicKey, keyPair.privateKey, clientPublicKey);
184
+ this.sessionKeys = { rx: keys.sharedRx, tx: keys.sharedTx };
185
+ const pushInit = sodium.crypto_secretstream_xchacha20poly1305_init_push(keys.sharedTx);
186
+ this.pushState = pushInit.state;
187
+ const response = {
188
+ t: "lunel_v2",
189
+ kind: "server_hello",
190
+ pubkey: sodium.to_base64(keyPair.publicKey, sodium.base64_variants.URLSAFE_NO_PADDING),
191
+ header: sodium.to_base64(pushInit.header, sodium.base64_variants.URLSAFE_NO_PADDING),
192
+ };
193
+ this.sendJsonFrame(response);
194
+ return;
195
+ }
196
+ if (frame.kind === "server_hello") {
197
+ if (this.options.role !== "app") {
198
+ throw new Error("unexpected server_hello on cli transport");
199
+ }
200
+ const serverPublicKey = sodium.from_base64(frame.pubkey, sodium.base64_variants.URLSAFE_NO_PADDING);
201
+ const serverHeader = sodium.from_base64(frame.header, sodium.base64_variants.URLSAFE_NO_PADDING);
202
+ const keyPair = this.ensureKeyPair();
203
+ const keys = sodium.crypto_kx_client_session_keys(keyPair.publicKey, keyPair.privateKey, serverPublicKey);
204
+ this.sessionKeys = { rx: keys.sharedRx, tx: keys.sharedTx };
205
+ this.pullState = sodium.crypto_secretstream_xchacha20poly1305_init_pull(serverHeader, keys.sharedRx);
206
+ const pushInit = sodium.crypto_secretstream_xchacha20poly1305_init_push(keys.sharedTx);
207
+ this.pushState = pushInit.state;
208
+ const ready = {
209
+ t: "lunel_v2",
210
+ kind: "client_ready",
211
+ header: sodium.to_base64(pushInit.header, sodium.base64_variants.URLSAFE_NO_PADDING),
212
+ };
213
+ this.sendJsonFrame(ready);
214
+ this.markSecure();
215
+ return;
216
+ }
217
+ if (frame.kind === "client_ready") {
218
+ if (this.options.role !== "cli") {
219
+ throw new Error("unexpected client_ready on app transport");
220
+ }
221
+ if (!this.sessionKeys) {
222
+ throw new Error("missing session keys before client_ready");
223
+ }
224
+ const clientHeader = sodium.from_base64(frame.header, sodium.base64_variants.URLSAFE_NO_PADDING);
225
+ this.pullState = sodium.crypto_secretstream_xchacha20poly1305_init_pull(clientHeader, this.sessionKeys.rx);
226
+ this.markSecure();
227
+ }
228
+ }
229
+ async encryptMessage(message) {
230
+ await sodium.ready;
231
+ if (this.state !== "secure" || !this.pushState) {
232
+ throw new Error("secure transport is not active");
233
+ }
234
+ const plaintext = sodium.from_string(JSON.stringify(message));
235
+ return sodium.crypto_secretstream_xchacha20poly1305_push(this.pushState, plaintext, null, sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE);
236
+ }
237
+ ensureKeyPair() {
238
+ if (this.keyPair)
239
+ return this.keyPair;
240
+ const pair = sodium.crypto_kx_keypair();
241
+ this.keyPair = {
242
+ publicKey: pair.publicKey,
243
+ privateKey: pair.privateKey,
244
+ };
245
+ return this.keyPair;
246
+ }
247
+ sendJsonFrame(frame) {
248
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
249
+ throw new Error("v2 transport is not connected");
250
+ }
251
+ this.ws.send(JSON.stringify(frame));
252
+ }
253
+ sendBinaryFrame(ciphertext) {
254
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
255
+ throw new Error("v2 transport is not connected");
256
+ }
257
+ const framed = encodeV2EncryptedFrame(ciphertext);
258
+ this.ws.send(Buffer.from(framed));
259
+ }
260
+ markSecure() {
261
+ this.state = "secure";
262
+ if (this.secureReadyResolve) {
263
+ this.secureReadyResolve();
264
+ this.secureReadyResolve = null;
265
+ this.secureReadyReject = null;
266
+ }
267
+ }
268
+ failSecure(error) {
269
+ if (this.secureReadyReject) {
270
+ this.secureReadyReject(error);
271
+ this.secureReadyResolve = null;
272
+ this.secureReadyReject = null;
273
+ }
274
+ }
275
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lunel-cli",
3
- "version": "0.1.84",
3
+ "version": "0.1.86",
4
4
  "author": [
5
5
  {
6
6
  "name": "Soham Bharambe",
@@ -28,6 +28,7 @@
28
28
  "dependencies": {
29
29
  "@opencode-ai/sdk": "^1.1.56",
30
30
  "ignore": "^6.0.2",
31
+ "libsodium-wrappers": "^0.7.15",
31
32
  "qrcode-terminal": "^0.12.0",
32
33
  "ws": "^8.18.0"
33
34
  },