lunel-cli 0.1.84 → 0.1.85
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 +128 -480
- package/dist/transport/protocol.d.ts +56 -0
- package/dist/transport/protocol.js +69 -0
- package/dist/transport/v2.d.ts +41 -0
- package/dist/transport/v2.js +273 -0
- package/package.json +2 -1
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
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
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
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
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
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(
|
|
1773
|
+
async function publishDiscoveredPorts(force = false) {
|
|
1957
1774
|
if (portScanInFlight)
|
|
1958
1775
|
return;
|
|
1959
|
-
if (
|
|
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
|
-
|
|
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(
|
|
1808
|
+
function startPortSync() {
|
|
1992
1809
|
stopPortSync();
|
|
1993
|
-
void publishDiscoveredPorts(
|
|
1810
|
+
void publishDiscoveredPorts(true);
|
|
1994
1811
|
portSyncTimer = setInterval(() => {
|
|
1995
|
-
void publishDiscoveredPorts(
|
|
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
|
|
2549
|
+
async function connectWebSocketV2() {
|
|
2768
2550
|
const gatewayUrl = currentPrimaryGateway;
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
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
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2565
|
+
if (message.type === "peer_connected") {
|
|
2566
|
+
console.log("App connected!\n");
|
|
2567
|
+
startPortSync();
|
|
2889
2568
|
return;
|
|
2890
2569
|
}
|
|
2891
|
-
if (
|
|
2892
|
-
|
|
2570
|
+
if (message.type === "peer_disconnected") {
|
|
2571
|
+
console.log("App disconnected. Waiting for reconnect window.\n");
|
|
2572
|
+
stopPortSync();
|
|
2893
2573
|
return;
|
|
2894
2574
|
}
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
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
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
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
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
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
|
|
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
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
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
|
|
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,273 @@
|
|
|
1
|
+
import { WebSocket } from "ws";
|
|
2
|
+
import sodium from "libsodium-wrappers";
|
|
3
|
+
import { V2_FRAME_ENCRYPTED_MESSAGE, buildSessionV2WsUrl, decodeV2BinaryFrame, encodeV2EncryptedFrame, isProtocolRequest, isProtocolResponse, isV2HandshakeFrame, } from "./protocol.js";
|
|
4
|
+
function toUint8Array(data) {
|
|
5
|
+
if (data instanceof Uint8Array)
|
|
6
|
+
return data;
|
|
7
|
+
if (Array.isArray(data))
|
|
8
|
+
return new Uint8Array(Buffer.concat(data.map((chunk) => Buffer.from(chunk))));
|
|
9
|
+
return new Uint8Array(data);
|
|
10
|
+
}
|
|
11
|
+
export class V2SessionTransport {
|
|
12
|
+
options;
|
|
13
|
+
ws = null;
|
|
14
|
+
closed = false;
|
|
15
|
+
state = "idle";
|
|
16
|
+
keyPair = null;
|
|
17
|
+
sessionKeys = null;
|
|
18
|
+
pushState = null;
|
|
19
|
+
pullState = null;
|
|
20
|
+
secureReadyResolve = null;
|
|
21
|
+
secureReadyReject = null;
|
|
22
|
+
secureReadyPromise = null;
|
|
23
|
+
constructor(options) {
|
|
24
|
+
this.options = options;
|
|
25
|
+
}
|
|
26
|
+
async connect() {
|
|
27
|
+
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
|
28
|
+
if (this.secureReadyPromise) {
|
|
29
|
+
return await this.secureReadyPromise;
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
await sodium.ready;
|
|
34
|
+
this.secureReadyPromise = new Promise((resolve, reject) => {
|
|
35
|
+
this.secureReadyResolve = resolve;
|
|
36
|
+
this.secureReadyReject = reject;
|
|
37
|
+
});
|
|
38
|
+
const wsUrl = buildSessionV2WsUrl(this.options.gatewayUrl, this.options.role, this.options.password);
|
|
39
|
+
await new Promise((resolve, reject) => {
|
|
40
|
+
const ws = new WebSocket(wsUrl);
|
|
41
|
+
let opened = false;
|
|
42
|
+
this.ws = ws;
|
|
43
|
+
this.closed = false;
|
|
44
|
+
this.state = "connecting";
|
|
45
|
+
ws.on("open", () => {
|
|
46
|
+
opened = true;
|
|
47
|
+
this.state = "open";
|
|
48
|
+
resolve();
|
|
49
|
+
});
|
|
50
|
+
ws.on("message", async (data) => {
|
|
51
|
+
try {
|
|
52
|
+
await this.handleMessage(data);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
this.options.debugLog?.("[transport:v2] message handling failed", error);
|
|
56
|
+
this.failSecure(new Error(error instanceof Error ? error.message : String(error)));
|
|
57
|
+
this.close();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
ws.on("close", (code, reason) => {
|
|
61
|
+
this.ws = null;
|
|
62
|
+
this.state = "closed";
|
|
63
|
+
if (!opened) {
|
|
64
|
+
reject(new Error(`v2 socket closed during setup (${code}: ${reason.toString()})`));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (!this.closed) {
|
|
68
|
+
this.closed = true;
|
|
69
|
+
this.failSecure(new Error(`v2 socket closed (${code}: ${reason.toString()})`));
|
|
70
|
+
this.options.handlers.onClose(`v2 socket closed (${code}: ${reason.toString()})`);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
ws.on("error", (error) => {
|
|
74
|
+
if (!opened) {
|
|
75
|
+
reject(new Error(`v2 socket error: ${error.message}`));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
this.options.debugLog?.("[transport:v2] websocket error", error.message);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
if (!this.secureReadyPromise) {
|
|
82
|
+
throw new Error("secure readiness promise missing");
|
|
83
|
+
}
|
|
84
|
+
await this.secureReadyPromise;
|
|
85
|
+
}
|
|
86
|
+
async sendMessage(message) {
|
|
87
|
+
const ciphertext = await this.encryptMessage(message);
|
|
88
|
+
this.sendBinaryFrame(ciphertext);
|
|
89
|
+
}
|
|
90
|
+
async sendResponse(response) {
|
|
91
|
+
const ciphertext = await this.encryptMessage(response);
|
|
92
|
+
this.sendBinaryFrame(ciphertext);
|
|
93
|
+
}
|
|
94
|
+
close() {
|
|
95
|
+
this.closed = true;
|
|
96
|
+
this.state = "closed";
|
|
97
|
+
if (!this.ws)
|
|
98
|
+
return;
|
|
99
|
+
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
100
|
+
this.ws.close();
|
|
101
|
+
}
|
|
102
|
+
this.ws = null;
|
|
103
|
+
}
|
|
104
|
+
async handleMessage(data) {
|
|
105
|
+
if (typeof data === "string") {
|
|
106
|
+
const raw = JSON.parse(data);
|
|
107
|
+
if ("type" in raw) {
|
|
108
|
+
await this.options.handlers.onSystemMessage(raw);
|
|
109
|
+
if (raw.type === "peer_connected") {
|
|
110
|
+
await this.maybeStartHandshake();
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (isV2HandshakeFrame(raw)) {
|
|
115
|
+
await this.handleHandshakeFrame(raw);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (this.state !== "secure") {
|
|
119
|
+
throw new Error("received plaintext app message before secure transport");
|
|
120
|
+
}
|
|
121
|
+
if (isProtocolResponse(raw)) {
|
|
122
|
+
await this.options.handlers.onProtocolResponse?.(raw);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (isProtocolRequest(raw)) {
|
|
126
|
+
const response = await this.options.handlers.onProtocolRequest(raw);
|
|
127
|
+
await this.sendResponse(response);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const bytes = toUint8Array(data);
|
|
133
|
+
const frame = decodeV2BinaryFrame(bytes);
|
|
134
|
+
if (!frame) {
|
|
135
|
+
throw new Error("invalid binary v2 frame");
|
|
136
|
+
}
|
|
137
|
+
if (frame.type !== V2_FRAME_ENCRYPTED_MESSAGE) {
|
|
138
|
+
throw new Error(`unsupported v2 frame type ${frame.type}`);
|
|
139
|
+
}
|
|
140
|
+
if (this.state !== "secure" || !this.pullState) {
|
|
141
|
+
throw new Error("received encrypted frame before secure transport");
|
|
142
|
+
}
|
|
143
|
+
const pulled = sodium.crypto_secretstream_xchacha20poly1305_pull(this.pullState, frame.payload);
|
|
144
|
+
if (!pulled.message) {
|
|
145
|
+
throw new Error("failed to decrypt v2 frame");
|
|
146
|
+
}
|
|
147
|
+
const parsed = JSON.parse(sodium.to_string(pulled.message));
|
|
148
|
+
if (isProtocolResponse(parsed)) {
|
|
149
|
+
await this.options.handlers.onProtocolResponse?.(parsed);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (isProtocolRequest(parsed)) {
|
|
153
|
+
const response = await this.options.handlers.onProtocolRequest(parsed);
|
|
154
|
+
await this.sendResponse(response);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
throw new Error("invalid decrypted protocol message");
|
|
158
|
+
}
|
|
159
|
+
async maybeStartHandshake() {
|
|
160
|
+
if (this.state === "secure" || this.state === "handshaking")
|
|
161
|
+
return;
|
|
162
|
+
if (this.options.role !== "app")
|
|
163
|
+
return;
|
|
164
|
+
this.state = "handshaking";
|
|
165
|
+
const keyPair = this.ensureKeyPair();
|
|
166
|
+
const hello = {
|
|
167
|
+
t: "lunel_v2",
|
|
168
|
+
kind: "client_hello",
|
|
169
|
+
pubkey: sodium.to_base64(keyPair.publicKey, sodium.base64_variants.URLSAFE_NO_PADDING),
|
|
170
|
+
};
|
|
171
|
+
this.sendJsonFrame(hello);
|
|
172
|
+
}
|
|
173
|
+
async handleHandshakeFrame(frame) {
|
|
174
|
+
this.state = "handshaking";
|
|
175
|
+
if (frame.kind === "client_hello") {
|
|
176
|
+
if (this.options.role !== "cli") {
|
|
177
|
+
throw new Error("unexpected client_hello on app transport");
|
|
178
|
+
}
|
|
179
|
+
const clientPublicKey = sodium.from_base64(frame.pubkey, sodium.base64_variants.URLSAFE_NO_PADDING);
|
|
180
|
+
const keyPair = this.ensureKeyPair();
|
|
181
|
+
const keys = sodium.crypto_kx_server_session_keys(keyPair.publicKey, keyPair.privateKey, clientPublicKey);
|
|
182
|
+
this.sessionKeys = { rx: keys.sharedRx, tx: keys.sharedTx };
|
|
183
|
+
const pushInit = sodium.crypto_secretstream_xchacha20poly1305_init_push(keys.sharedTx);
|
|
184
|
+
this.pushState = pushInit.state;
|
|
185
|
+
const response = {
|
|
186
|
+
t: "lunel_v2",
|
|
187
|
+
kind: "server_hello",
|
|
188
|
+
pubkey: sodium.to_base64(keyPair.publicKey, sodium.base64_variants.URLSAFE_NO_PADDING),
|
|
189
|
+
header: sodium.to_base64(pushInit.header, sodium.base64_variants.URLSAFE_NO_PADDING),
|
|
190
|
+
};
|
|
191
|
+
this.sendJsonFrame(response);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (frame.kind === "server_hello") {
|
|
195
|
+
if (this.options.role !== "app") {
|
|
196
|
+
throw new Error("unexpected server_hello on cli transport");
|
|
197
|
+
}
|
|
198
|
+
const serverPublicKey = sodium.from_base64(frame.pubkey, sodium.base64_variants.URLSAFE_NO_PADDING);
|
|
199
|
+
const serverHeader = sodium.from_base64(frame.header, sodium.base64_variants.URLSAFE_NO_PADDING);
|
|
200
|
+
const keyPair = this.ensureKeyPair();
|
|
201
|
+
const keys = sodium.crypto_kx_client_session_keys(keyPair.publicKey, keyPair.privateKey, serverPublicKey);
|
|
202
|
+
this.sessionKeys = { rx: keys.sharedRx, tx: keys.sharedTx };
|
|
203
|
+
this.pullState = sodium.crypto_secretstream_xchacha20poly1305_init_pull(serverHeader, keys.sharedRx);
|
|
204
|
+
const pushInit = sodium.crypto_secretstream_xchacha20poly1305_init_push(keys.sharedTx);
|
|
205
|
+
this.pushState = pushInit.state;
|
|
206
|
+
const ready = {
|
|
207
|
+
t: "lunel_v2",
|
|
208
|
+
kind: "client_ready",
|
|
209
|
+
header: sodium.to_base64(pushInit.header, sodium.base64_variants.URLSAFE_NO_PADDING),
|
|
210
|
+
};
|
|
211
|
+
this.sendJsonFrame(ready);
|
|
212
|
+
this.markSecure();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (frame.kind === "client_ready") {
|
|
216
|
+
if (this.options.role !== "cli") {
|
|
217
|
+
throw new Error("unexpected client_ready on app transport");
|
|
218
|
+
}
|
|
219
|
+
if (!this.sessionKeys) {
|
|
220
|
+
throw new Error("missing session keys before client_ready");
|
|
221
|
+
}
|
|
222
|
+
const clientHeader = sodium.from_base64(frame.header, sodium.base64_variants.URLSAFE_NO_PADDING);
|
|
223
|
+
this.pullState = sodium.crypto_secretstream_xchacha20poly1305_init_pull(clientHeader, this.sessionKeys.rx);
|
|
224
|
+
this.markSecure();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async encryptMessage(message) {
|
|
228
|
+
await sodium.ready;
|
|
229
|
+
if (this.state !== "secure" || !this.pushState) {
|
|
230
|
+
throw new Error("secure transport is not active");
|
|
231
|
+
}
|
|
232
|
+
const plaintext = sodium.from_string(JSON.stringify(message));
|
|
233
|
+
return sodium.crypto_secretstream_xchacha20poly1305_push(this.pushState, plaintext, null, sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE);
|
|
234
|
+
}
|
|
235
|
+
ensureKeyPair() {
|
|
236
|
+
if (this.keyPair)
|
|
237
|
+
return this.keyPair;
|
|
238
|
+
const pair = sodium.crypto_kx_keypair();
|
|
239
|
+
this.keyPair = {
|
|
240
|
+
publicKey: pair.publicKey,
|
|
241
|
+
privateKey: pair.privateKey,
|
|
242
|
+
};
|
|
243
|
+
return this.keyPair;
|
|
244
|
+
}
|
|
245
|
+
sendJsonFrame(frame) {
|
|
246
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
247
|
+
throw new Error("v2 transport is not connected");
|
|
248
|
+
}
|
|
249
|
+
this.ws.send(JSON.stringify(frame));
|
|
250
|
+
}
|
|
251
|
+
sendBinaryFrame(ciphertext) {
|
|
252
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
253
|
+
throw new Error("v2 transport is not connected");
|
|
254
|
+
}
|
|
255
|
+
const framed = encodeV2EncryptedFrame(ciphertext);
|
|
256
|
+
this.ws.send(Buffer.from(framed));
|
|
257
|
+
}
|
|
258
|
+
markSecure() {
|
|
259
|
+
this.state = "secure";
|
|
260
|
+
if (this.secureReadyResolve) {
|
|
261
|
+
this.secureReadyResolve();
|
|
262
|
+
this.secureReadyResolve = null;
|
|
263
|
+
this.secureReadyReject = null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
failSecure(error) {
|
|
267
|
+
if (this.secureReadyReject) {
|
|
268
|
+
this.secureReadyReject(error);
|
|
269
|
+
this.secureReadyResolve = null;
|
|
270
|
+
this.secureReadyReject = null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lunel-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.85",
|
|
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
|
},
|