lunel-cli 0.1.40 → 0.1.43
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 +412 -50
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -11,8 +11,10 @@ import * as os from "os";
|
|
|
11
11
|
import { spawn, execSync, execFileSync } from "child_process";
|
|
12
12
|
import { createServer, createConnection } from "net";
|
|
13
13
|
import { createInterface } from "readline";
|
|
14
|
-
const DEFAULT_PROXY_URL = process.env.LUNEL_PROXY_URL || "https://gateway.lunel.dev";
|
|
15
|
-
const MANAGER_URL = process.env.LUNEL_MANAGER_URL || "https://manager.lunel.dev";
|
|
14
|
+
const DEFAULT_PROXY_URL = normalizeGatewayUrl(process.env.LUNEL_PROXY_URL || "https://gateway.lunel.dev");
|
|
15
|
+
const MANAGER_URL = normalizeGatewayUrl(process.env.LUNEL_MANAGER_URL || "https://manager.lunel.dev");
|
|
16
|
+
const CLOUD_JOB_CONFIG_PATH = "/etc/lunel/job.json";
|
|
17
|
+
const VM_HEARTBEAT_INTERVAL_MS = 10_000;
|
|
16
18
|
const CLI_ARGS = process.argv.slice(2);
|
|
17
19
|
import { createRequire } from "module";
|
|
18
20
|
const __require = createRequire(import.meta.url);
|
|
@@ -33,6 +35,8 @@ const processOutputBuffers = new Map();
|
|
|
33
35
|
let lastCpuInfo = null;
|
|
34
36
|
// OpenCode client
|
|
35
37
|
let opencodeClient = null;
|
|
38
|
+
// Tracks the most recently used OpenCode session ID so we can validate it after SSE reconnects.
|
|
39
|
+
let lastActiveOpenCodeSessionId = null;
|
|
36
40
|
// Proxy tunnel management
|
|
37
41
|
let currentSessionCode = null;
|
|
38
42
|
let currentSessionPassword = null;
|
|
@@ -44,6 +48,51 @@ let activeControlWs = null;
|
|
|
44
48
|
let activeDataWs = null;
|
|
45
49
|
const activeTunnels = new Map();
|
|
46
50
|
const PORT_SYNC_INTERVAL_MS = 30_000;
|
|
51
|
+
let cloudJobConfig = null;
|
|
52
|
+
let vmHeartbeatTimer = null;
|
|
53
|
+
async function readCloudJobConfig() {
|
|
54
|
+
try {
|
|
55
|
+
const data = await fs.readFile(CLOUD_JOB_CONFIG_PATH, "utf-8");
|
|
56
|
+
return JSON.parse(data);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function startVmHeartbeat(resumeToken) {
|
|
63
|
+
if (!cloudJobConfig?.session_code)
|
|
64
|
+
return;
|
|
65
|
+
if (vmHeartbeatTimer)
|
|
66
|
+
return;
|
|
67
|
+
const cfg = cloudJobConfig;
|
|
68
|
+
const sendHeartbeat = () => {
|
|
69
|
+
if (shuttingDown)
|
|
70
|
+
return;
|
|
71
|
+
fetch(`${MANAGER_URL}/v1/vm/heartbeat`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "Content-Type": "application/json" },
|
|
74
|
+
body: JSON.stringify({
|
|
75
|
+
sandboxId: cfg.sandbox_id,
|
|
76
|
+
resumeToken,
|
|
77
|
+
sandmanUrl: cfg.sandman_url || "",
|
|
78
|
+
repoUrl: cfg.repo_url,
|
|
79
|
+
branch: cfg.branch,
|
|
80
|
+
vmProfile: cfg.vm_profile || "",
|
|
81
|
+
}),
|
|
82
|
+
}).catch((err) => {
|
|
83
|
+
if (!shuttingDown)
|
|
84
|
+
console.warn("[vm] heartbeat failed:", err.message);
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
sendHeartbeat();
|
|
88
|
+
vmHeartbeatTimer = setInterval(sendHeartbeat, VM_HEARTBEAT_INTERVAL_MS);
|
|
89
|
+
}
|
|
90
|
+
function stopVmHeartbeat() {
|
|
91
|
+
if (vmHeartbeatTimer) {
|
|
92
|
+
clearInterval(vmHeartbeatTimer);
|
|
93
|
+
vmHeartbeatTimer = null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
47
96
|
const CLI_LOCAL_TCP_CONNECT_TIMEOUT_MS = 2_500;
|
|
48
97
|
const PROXY_WS_CONNECT_TIMEOUT_MS = 12_000;
|
|
49
98
|
const TUNNEL_SETUP_BUDGET_MS = 18_000;
|
|
@@ -781,6 +830,166 @@ async function handleGitDiscard(payload) {
|
|
|
781
830
|
// Terminal Handlers (delegates to Rust PTY binary)
|
|
782
831
|
// ============================================================================
|
|
783
832
|
let dataChannel = null;
|
|
833
|
+
let e2eeKeyPair = null;
|
|
834
|
+
let e2eeCliToAppKey = null; // AES-256-GCM key, CLI→App direction
|
|
835
|
+
let e2eeAppToCliKey = null; // AES-256-GCM key, App→CLI direction
|
|
836
|
+
let e2eeActive = false;
|
|
837
|
+
let e2eeSentReady = false;
|
|
838
|
+
let e2eeGotReady = false;
|
|
839
|
+
let e2eeOutboundCounter = 0;
|
|
840
|
+
function e2eeReset() {
|
|
841
|
+
e2eeKeyPair = null;
|
|
842
|
+
e2eeCliToAppKey = null;
|
|
843
|
+
e2eeAppToCliKey = null;
|
|
844
|
+
e2eeActive = false;
|
|
845
|
+
e2eeSentReady = false;
|
|
846
|
+
e2eeGotReady = false;
|
|
847
|
+
e2eeOutboundCounter = 0;
|
|
848
|
+
}
|
|
849
|
+
function e2eeHandlePeerHello(peerPubkeyB64, dataWs) {
|
|
850
|
+
const kp = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
851
|
+
e2eeKeyPair = kp;
|
|
852
|
+
const peerDer = Buffer.from(peerPubkeyB64, "base64url");
|
|
853
|
+
const peerPubKey = crypto.createPublicKey({ key: peerDer, type: "spki", format: "der" });
|
|
854
|
+
const sharedSecret = crypto.diffieHellman({ privateKey: kp.privateKey, publicKey: peerPubKey });
|
|
855
|
+
const salt = Buffer.alloc(32);
|
|
856
|
+
e2eeCliToAppKey = Buffer.from(crypto.hkdfSync("sha256", sharedSecret, salt, Buffer.from("lunel-cli-to-app"), 32));
|
|
857
|
+
e2eeAppToCliKey = Buffer.from(crypto.hkdfSync("sha256", sharedSecret, salt, Buffer.from("lunel-app-to-cli"), 32));
|
|
858
|
+
// Send our own pubkey so the app can derive the same shared secret
|
|
859
|
+
const mySpki = kp.publicKey.export({ type: "spki", format: "der" });
|
|
860
|
+
dataWs.send(JSON.stringify({ type: "e2ee_hello", pubkey: Buffer.from(mySpki).toString("base64url") }));
|
|
861
|
+
dataWs.send(JSON.stringify({ type: "e2ee_secure_ready" }));
|
|
862
|
+
e2eeSentReady = true;
|
|
863
|
+
if (e2eeGotReady) {
|
|
864
|
+
e2eeActive = true;
|
|
865
|
+
console.log("[e2ee] encryption active");
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
function e2eeHandlePeerReady() {
|
|
869
|
+
e2eeGotReady = true;
|
|
870
|
+
if (e2eeSentReady) {
|
|
871
|
+
e2eeActive = true;
|
|
872
|
+
console.log("[e2ee] encryption active");
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
function e2eeEncrypt(payload) {
|
|
876
|
+
if (!e2eeCliToAppKey)
|
|
877
|
+
throw new Error("[e2ee] CLI→App key not ready");
|
|
878
|
+
const nonce = Buffer.alloc(12);
|
|
879
|
+
nonce.writeUInt32BE(e2eeOutboundCounter++, 8);
|
|
880
|
+
const plain = Buffer.from(JSON.stringify(payload));
|
|
881
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", e2eeCliToAppKey, nonce);
|
|
882
|
+
const ct = Buffer.concat([cipher.update(plain), cipher.final()]);
|
|
883
|
+
const tag = cipher.getAuthTag();
|
|
884
|
+
// wire: nonce(12) || ct || tag(16) — consistent with WebCrypto AES-GCM format on app side
|
|
885
|
+
return Buffer.concat([nonce, ct, tag]).toString("base64url");
|
|
886
|
+
}
|
|
887
|
+
function e2eeDecrypt(enc) {
|
|
888
|
+
if (!e2eeAppToCliKey)
|
|
889
|
+
throw new Error("[e2ee] App→CLI key not ready");
|
|
890
|
+
const buf = Buffer.from(enc, "base64url");
|
|
891
|
+
const nonce = buf.subarray(0, 12);
|
|
892
|
+
const ct = buf.subarray(12, buf.length - 16);
|
|
893
|
+
const tag = buf.subarray(buf.length - 16);
|
|
894
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", e2eeAppToCliKey, nonce);
|
|
895
|
+
decipher.setAuthTag(tag);
|
|
896
|
+
return JSON.parse(Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf-8"));
|
|
897
|
+
}
|
|
898
|
+
// ============================================================================
|
|
899
|
+
// Replay buffer + backpressure
|
|
900
|
+
// ============================================================================
|
|
901
|
+
// Replay buffer for sequenced outbound data-channel messages
|
|
902
|
+
let outboundSeq = 0;
|
|
903
|
+
let lastSentSeq = 0; // highest seq actually flushed to the socket (not paused)
|
|
904
|
+
const REPLAY_BUFFER_MAX_MESSAGES = 500;
|
|
905
|
+
const REPLAY_BUFFER_MAX_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
906
|
+
const replayBuffer = [];
|
|
907
|
+
let replayBufferBytes = 0;
|
|
908
|
+
// Data-channel backpressure
|
|
909
|
+
const DATA_CHANNEL_HIGH_WATER_BYTES = 1 * 1024 * 1024; // 1 MB — pause forwarding
|
|
910
|
+
const DATA_CHANNEL_LOW_WATER_BYTES = 256 * 1024; // 256 KB — resume forwarding
|
|
911
|
+
let dataChannelPaused = false;
|
|
912
|
+
let dataChannelDrainTimer = null;
|
|
913
|
+
function sendOnDataChannel(ws, msg) {
|
|
914
|
+
if (e2eeActive && e2eeCliToAppKey) {
|
|
915
|
+
const { payload, ...envelope } = msg;
|
|
916
|
+
ws.send(JSON.stringify({ ...envelope, enc: e2eeEncrypt(payload) }));
|
|
917
|
+
}
|
|
918
|
+
else {
|
|
919
|
+
ws.send(JSON.stringify(msg));
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
function sendSequenced(channel, msg) {
|
|
923
|
+
const seq = ++outboundSeq;
|
|
924
|
+
const msgWithSeq = { ...msg, seq };
|
|
925
|
+
const byteLen = Buffer.byteLength(JSON.stringify(msgWithSeq), "utf-8");
|
|
926
|
+
replayBuffer.push({ seq, msg: msgWithSeq, byteLen });
|
|
927
|
+
replayBufferBytes += byteLen;
|
|
928
|
+
while (replayBuffer.length > REPLAY_BUFFER_MAX_MESSAGES || replayBufferBytes > REPLAY_BUFFER_MAX_BYTES) {
|
|
929
|
+
const evicted = replayBuffer.shift();
|
|
930
|
+
if (evicted)
|
|
931
|
+
replayBufferBytes -= evicted.byteLen;
|
|
932
|
+
}
|
|
933
|
+
// Skip the actual send while backpressured — event is already in replay buffer
|
|
934
|
+
if (channel.readyState === WebSocket.OPEN && !dataChannelPaused) {
|
|
935
|
+
sendOnDataChannel(channel, msgWithSeq);
|
|
936
|
+
lastSentSeq = seq;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
function resetReplayBuffer() {
|
|
940
|
+
outboundSeq = 0;
|
|
941
|
+
lastSentSeq = 0;
|
|
942
|
+
replayBuffer.length = 0;
|
|
943
|
+
replayBufferBytes = 0;
|
|
944
|
+
dataChannelPaused = false;
|
|
945
|
+
e2eeReset();
|
|
946
|
+
if (dataChannelDrainTimer) {
|
|
947
|
+
clearInterval(dataChannelDrainTimer);
|
|
948
|
+
dataChannelDrainTimer = null;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
function resumeDataChannel() {
|
|
952
|
+
if (!dataChannelPaused)
|
|
953
|
+
return;
|
|
954
|
+
dataChannelPaused = false;
|
|
955
|
+
if (dataChannelDrainTimer) {
|
|
956
|
+
clearInterval(dataChannelDrainTimer);
|
|
957
|
+
dataChannelDrainTimer = null;
|
|
958
|
+
}
|
|
959
|
+
// Flush events that were buffered during the pause (re-encrypts with current key)
|
|
960
|
+
const toFlush = replayBuffer.filter((e) => e.seq > lastSentSeq);
|
|
961
|
+
if (dataChannel?.readyState === WebSocket.OPEN) {
|
|
962
|
+
for (const entry of toFlush) {
|
|
963
|
+
sendOnDataChannel(dataChannel, entry.msg);
|
|
964
|
+
lastSentSeq = entry.seq;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
console.log(`[backpressure] data channel resumed, flushed ${toFlush.length} buffered events`);
|
|
968
|
+
if (activeControlWs?.readyState === WebSocket.OPEN) {
|
|
969
|
+
const buffered = dataChannel?.bufferedAmount ?? 0;
|
|
970
|
+
activeControlWs.send(JSON.stringify({ type: "data_channel_resumed", bufferedBytes: buffered }));
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
function checkDataChannelBackpressure() {
|
|
974
|
+
if (!dataChannel || dataChannel.readyState !== WebSocket.OPEN)
|
|
975
|
+
return;
|
|
976
|
+
const buffered = dataChannel.bufferedAmount ?? 0;
|
|
977
|
+
if (!dataChannelPaused && buffered > DATA_CHANNEL_HIGH_WATER_BYTES) {
|
|
978
|
+
dataChannelPaused = true;
|
|
979
|
+
console.warn(`[backpressure] data channel paused (${buffered} bytes buffered)`);
|
|
980
|
+
if (activeControlWs?.readyState === WebSocket.OPEN) {
|
|
981
|
+
activeControlWs.send(JSON.stringify({ type: "data_channel_paused", bufferedBytes: buffered }));
|
|
982
|
+
}
|
|
983
|
+
if (!dataChannelDrainTimer) {
|
|
984
|
+
dataChannelDrainTimer = setInterval(() => {
|
|
985
|
+
const current = dataChannel?.bufferedAmount ?? 0;
|
|
986
|
+
if (current < DATA_CHANNEL_LOW_WATER_BYTES || !dataChannel || dataChannel.readyState !== WebSocket.OPEN) {
|
|
987
|
+
resumeDataChannel();
|
|
988
|
+
}
|
|
989
|
+
}, 100);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
784
993
|
let ensurePtyBinaryPromise = null;
|
|
785
994
|
function normalizeJsonWithTrailingCommas(text) {
|
|
786
995
|
return text.replace(/,\s*([}\]])/g, "$1");
|
|
@@ -991,7 +1200,7 @@ async function ensurePtyProcess() {
|
|
|
991
1200
|
scrollbackLength: event.scrollbackLength,
|
|
992
1201
|
},
|
|
993
1202
|
};
|
|
994
|
-
dataChannel
|
|
1203
|
+
sendSequenced(dataChannel, msg);
|
|
995
1204
|
}
|
|
996
1205
|
}
|
|
997
1206
|
else if (event.event === "exit") {
|
|
@@ -1004,7 +1213,7 @@ async function ensurePtyProcess() {
|
|
|
1004
1213
|
action: "exit",
|
|
1005
1214
|
payload: { terminalId: event.id, code: event.code },
|
|
1006
1215
|
};
|
|
1007
|
-
dataChannel
|
|
1216
|
+
sendSequenced(dataChannel, msg);
|
|
1008
1217
|
}
|
|
1009
1218
|
}
|
|
1010
1219
|
else if (event.event === "error") {
|
|
@@ -1155,7 +1364,7 @@ function handleProcessesSpawn(payload) {
|
|
|
1155
1364
|
action: "output",
|
|
1156
1365
|
payload: { pid, channel, stream, data: text },
|
|
1157
1366
|
};
|
|
1158
|
-
dataChannel
|
|
1367
|
+
sendSequenced(dataChannel, msg);
|
|
1159
1368
|
}
|
|
1160
1369
|
};
|
|
1161
1370
|
proc.stdout?.on("data", sendOutput("stdout"));
|
|
@@ -1634,6 +1843,8 @@ async function handleAiGetMessages(payload) {
|
|
|
1634
1843
|
}
|
|
1635
1844
|
async function handleAiPrompt(payload) {
|
|
1636
1845
|
const sessionId = payload.sessionId;
|
|
1846
|
+
if (sessionId)
|
|
1847
|
+
lastActiveOpenCodeSessionId = sessionId;
|
|
1637
1848
|
const text = payload.text;
|
|
1638
1849
|
const model = payload.model;
|
|
1639
1850
|
const agent = payload.agent;
|
|
@@ -1656,7 +1867,7 @@ async function handleAiPrompt(payload) {
|
|
|
1656
1867
|
}).catch((err) => {
|
|
1657
1868
|
console.error("[ai] prompt error:", err.message);
|
|
1658
1869
|
if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
|
|
1659
|
-
dataChannel
|
|
1870
|
+
sendSequenced(dataChannel, {
|
|
1660
1871
|
v: 1,
|
|
1661
1872
|
id: `evt-${Date.now()}`,
|
|
1662
1873
|
ns: "ai",
|
|
@@ -1665,7 +1876,7 @@ async function handleAiPrompt(payload) {
|
|
|
1665
1876
|
type: "prompt_error",
|
|
1666
1877
|
properties: { sessionId, error: err.message },
|
|
1667
1878
|
},
|
|
1668
|
-
})
|
|
1879
|
+
});
|
|
1669
1880
|
}
|
|
1670
1881
|
});
|
|
1671
1882
|
return { ack: true };
|
|
@@ -1765,43 +1976,99 @@ async function handleAiPermissionReply(payload) {
|
|
|
1765
1976
|
return {};
|
|
1766
1977
|
}
|
|
1767
1978
|
// SSE event forwarding from OpenCode to mobile app
|
|
1979
|
+
const SSE_BACKOFF_INITIAL_MS = 500;
|
|
1980
|
+
const SSE_BACKOFF_CAP_MS = 30_000;
|
|
1981
|
+
const SSE_MAX_RETRIES = 20;
|
|
1768
1982
|
async function subscribeToOpenCodeEvents(client) {
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
if (
|
|
1780
|
-
|
|
1781
|
-
|
|
1983
|
+
let attempt = 0;
|
|
1984
|
+
const backoffMs = (n) => {
|
|
1985
|
+
const base = Math.min(SSE_BACKOFF_INITIAL_MS * 2 ** n, SSE_BACKOFF_CAP_MS);
|
|
1986
|
+
const jitter = Math.random() * base * 0.3;
|
|
1987
|
+
return Math.round(base + jitter);
|
|
1988
|
+
};
|
|
1989
|
+
while (!shuttingDown) {
|
|
1990
|
+
try {
|
|
1991
|
+
// On reconnect, verify the active OpenCode session is still alive.
|
|
1992
|
+
// If OpenCode garbage-collected it, notify the app before resuming.
|
|
1993
|
+
if (attempt > 0 && lastActiveOpenCodeSessionId) {
|
|
1994
|
+
const checkResp = await client.session.get({ path: { id: lastActiveOpenCodeSessionId } });
|
|
1995
|
+
if (checkResp.error) {
|
|
1996
|
+
console.warn(`[sse] OpenCode session ${lastActiveOpenCodeSessionId} was garbage-collected. Notifying app.`);
|
|
1997
|
+
const gcSessionId = lastActiveOpenCodeSessionId;
|
|
1998
|
+
lastActiveOpenCodeSessionId = null;
|
|
1999
|
+
if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
|
|
2000
|
+
sendSequenced(dataChannel, {
|
|
2001
|
+
v: 1,
|
|
2002
|
+
id: `evt-${Date.now()}`,
|
|
2003
|
+
ns: "ai",
|
|
2004
|
+
action: "event",
|
|
2005
|
+
payload: { type: "session_gc", properties: { sessionId: gcSessionId } },
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
else {
|
|
2010
|
+
console.log(`[sse] Active session ${lastActiveOpenCodeSessionId} still valid.`);
|
|
2011
|
+
}
|
|
1782
2012
|
}
|
|
1783
|
-
|
|
1784
|
-
if (
|
|
1785
|
-
|
|
1786
|
-
v: 1,
|
|
1787
|
-
id: `evt-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`,
|
|
1788
|
-
ns: "ai",
|
|
1789
|
-
action: "event",
|
|
1790
|
-
payload: {
|
|
1791
|
-
type: base.type,
|
|
1792
|
-
properties: base.properties || {},
|
|
1793
|
-
},
|
|
1794
|
-
};
|
|
1795
|
-
dataChannel.send(JSON.stringify(msg));
|
|
2013
|
+
const events = await client.event.subscribe();
|
|
2014
|
+
if (attempt > 0) {
|
|
2015
|
+
console.log(`[sse] reconnected after ${attempt} attempt(s)`);
|
|
1796
2016
|
}
|
|
2017
|
+
attempt = 0; // reset on successful connect
|
|
2018
|
+
for await (const raw of events.stream) {
|
|
2019
|
+
if (shuttingDown)
|
|
2020
|
+
return;
|
|
2021
|
+
// OpenCode SSE payload shapes vary across versions:
|
|
2022
|
+
// { type, properties, ... }
|
|
2023
|
+
// { payload: { type, properties, ... }, directory: "..." }
|
|
2024
|
+
const parsed = raw;
|
|
2025
|
+
const base = parsed?.payload && typeof parsed.payload === "object"
|
|
2026
|
+
? parsed.payload
|
|
2027
|
+
: parsed;
|
|
2028
|
+
if (!base || typeof base.type !== "string") {
|
|
2029
|
+
console.warn("[sse] Dropped malformed event:", redactSensitive(JSON.stringify(parsed).substring(0, 200)));
|
|
2030
|
+
continue;
|
|
2031
|
+
}
|
|
2032
|
+
console.log("[sse]", base.type);
|
|
2033
|
+
if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
|
|
2034
|
+
const msg = {
|
|
2035
|
+
v: 1,
|
|
2036
|
+
id: `evt-${Date.now()}`,
|
|
2037
|
+
ns: "ai",
|
|
2038
|
+
action: "event",
|
|
2039
|
+
payload: {
|
|
2040
|
+
type: base.type,
|
|
2041
|
+
properties: base.properties || {},
|
|
2042
|
+
},
|
|
2043
|
+
};
|
|
2044
|
+
sendSequenced(dataChannel, msg);
|
|
2045
|
+
checkDataChannelBackpressure();
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
// Stream ended normally — reconnect immediately (attempt stays 0 for normal end)
|
|
2049
|
+
console.log("[sse] Event stream ended, reconnecting...");
|
|
2050
|
+
}
|
|
2051
|
+
catch (err) {
|
|
2052
|
+
if (shuttingDown)
|
|
2053
|
+
return;
|
|
2054
|
+
attempt++;
|
|
2055
|
+
const delay = backoffMs(attempt - 1);
|
|
2056
|
+
console.error(`[sse] Stream error (attempt ${attempt}/${SSE_MAX_RETRIES}): ${err.message}. Retrying in ${delay}ms`);
|
|
2057
|
+
if (attempt >= SSE_MAX_RETRIES) {
|
|
2058
|
+
console.error("[sse] Max retries reached. Sending error event to app and giving up.");
|
|
2059
|
+
if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
|
|
2060
|
+
sendSequenced(dataChannel, {
|
|
2061
|
+
v: 1,
|
|
2062
|
+
id: `evt-${Date.now()}`,
|
|
2063
|
+
ns: "ai",
|
|
2064
|
+
action: "event",
|
|
2065
|
+
payload: { type: "sse_dead", properties: { error: err.message, attempts: attempt } },
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1797
2071
|
}
|
|
1798
|
-
// Stream ended normally — reconnect
|
|
1799
|
-
console.log("[sse] Event stream ended, reconnecting...");
|
|
1800
|
-
setTimeout(() => subscribeToOpenCodeEvents(client), 1000);
|
|
1801
|
-
}
|
|
1802
|
-
catch (err) {
|
|
1803
|
-
console.error("[sse] Event stream error:", err.message);
|
|
1804
|
-
setTimeout(() => subscribeToOpenCodeEvents(client), 3000);
|
|
1805
2072
|
}
|
|
1806
2073
|
}
|
|
1807
2074
|
// Proxy Handlers
|
|
@@ -1943,7 +2210,10 @@ async function handleProxyConnect(payload) {
|
|
|
1943
2210
|
throw tcpConnectError || Object.assign(new Error(`TCP connect failed to localhost:${port}`), { code: "ECONNREFUSED" });
|
|
1944
2211
|
}
|
|
1945
2212
|
// 2. Open proxy WebSocket to gateway
|
|
1946
|
-
const wsBase = activeGatewayUrl.replace(/^
|
|
2213
|
+
const wsBase = activeGatewayUrl.replace(/^https:/, "wss:");
|
|
2214
|
+
if (!wsBase.startsWith("wss://")) {
|
|
2215
|
+
throw Object.assign(new Error("Gateway URL must use https://"), { code: "EPROTO" });
|
|
2216
|
+
}
|
|
1947
2217
|
const authQuery = currentSessionPassword
|
|
1948
2218
|
? `password=${encodeURIComponent(currentSessionPassword)}`
|
|
1949
2219
|
: `code=${encodeURIComponent(currentSessionCode)}`;
|
|
@@ -2424,12 +2694,35 @@ async function processMessage(message) {
|
|
|
2424
2694
|
}
|
|
2425
2695
|
}
|
|
2426
2696
|
function sendResponseOnData(response, dataWs) {
|
|
2427
|
-
|
|
2697
|
+
if (e2eeActive && e2eeCliToAppKey) {
|
|
2698
|
+
const { payload, ...envelope } = response;
|
|
2699
|
+
dataWs.send(JSON.stringify({ ...envelope, enc: e2eeEncrypt(payload) }));
|
|
2700
|
+
}
|
|
2701
|
+
else {
|
|
2702
|
+
dataWs.send(JSON.stringify(response));
|
|
2703
|
+
}
|
|
2428
2704
|
}
|
|
2429
2705
|
function normalizeGatewayUrl(input) {
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2706
|
+
const raw = input.trim();
|
|
2707
|
+
if (!raw) {
|
|
2708
|
+
throw new Error("Gateway URL is required");
|
|
2709
|
+
}
|
|
2710
|
+
if (raw.toLowerCase().startsWith("http://") || raw.toLowerCase().startsWith("ws://")) {
|
|
2711
|
+
throw new Error("Insecure gateway protocol is not allowed; use https://");
|
|
2712
|
+
}
|
|
2713
|
+
const withScheme = /^[a-z]+:\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
2714
|
+
let parsed;
|
|
2715
|
+
try {
|
|
2716
|
+
parsed = new URL(withScheme);
|
|
2717
|
+
}
|
|
2718
|
+
catch {
|
|
2719
|
+
throw new Error(`Invalid gateway URL: ${input}`);
|
|
2720
|
+
}
|
|
2721
|
+
if (parsed.protocol !== "https:") {
|
|
2722
|
+
throw new Error("Gateway URL must use https://");
|
|
2723
|
+
}
|
|
2724
|
+
const path = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "");
|
|
2725
|
+
return `${parsed.protocol}//${parsed.host}${path}`;
|
|
2433
2726
|
}
|
|
2434
2727
|
async function createSessionFromManager() {
|
|
2435
2728
|
const response = await fetch(`${MANAGER_URL}/v1/session`, {
|
|
@@ -2441,6 +2734,13 @@ async function createSessionFromManager() {
|
|
|
2441
2734
|
}
|
|
2442
2735
|
return (await response.json());
|
|
2443
2736
|
}
|
|
2737
|
+
async function connectToCloudSession(sessionCode) {
|
|
2738
|
+
const response = await fetch(`${MANAGER_URL}/v1/session/${sessionCode}`);
|
|
2739
|
+
if (!response.ok) {
|
|
2740
|
+
throw new Error(`Failed to look up cloud session ${sessionCode}: ${response.status}`);
|
|
2741
|
+
}
|
|
2742
|
+
return (await response.json());
|
|
2743
|
+
}
|
|
2444
2744
|
function displayQR(primaryGateway, backupGateway, code) {
|
|
2445
2745
|
console.log("\n");
|
|
2446
2746
|
qrcode.generate(code, { small: true }, (qr) => {
|
|
@@ -2456,7 +2756,10 @@ function displayQR(primaryGateway, backupGateway, code) {
|
|
|
2456
2756
|
});
|
|
2457
2757
|
}
|
|
2458
2758
|
function buildWsUrl(gatewayUrl, role, channel) {
|
|
2459
|
-
const wsBase = gatewayUrl.replace(/^
|
|
2759
|
+
const wsBase = gatewayUrl.replace(/^https:/, "wss:");
|
|
2760
|
+
if (!wsBase.startsWith("wss://")) {
|
|
2761
|
+
throw new Error("Gateway URL must use https://");
|
|
2762
|
+
}
|
|
2460
2763
|
const query = new URLSearchParams();
|
|
2461
2764
|
if (currentSessionPassword) {
|
|
2462
2765
|
query.set("password", currentSessionPassword);
|
|
@@ -2475,6 +2778,7 @@ function buildWsUrl(gatewayUrl, role, channel) {
|
|
|
2475
2778
|
function gracefulShutdown() {
|
|
2476
2779
|
shuttingDown = true;
|
|
2477
2780
|
console.log("\nShutting down...");
|
|
2781
|
+
stopVmHeartbeat();
|
|
2478
2782
|
stopPortSync();
|
|
2479
2783
|
if (ptyProcess) {
|
|
2480
2784
|
ptyProcess.kill();
|
|
@@ -2552,6 +2856,8 @@ async function connectWebSocket() {
|
|
|
2552
2856
|
if (message.type === "connected")
|
|
2553
2857
|
return;
|
|
2554
2858
|
if (message.type === "session_password" && message.password) {
|
|
2859
|
+
if (!currentSessionPassword)
|
|
2860
|
+
resetReplayBuffer(); // new session
|
|
2555
2861
|
currentSessionPassword = message.password;
|
|
2556
2862
|
console.log("[session] received reconnect password");
|
|
2557
2863
|
return;
|
|
@@ -2617,9 +2923,41 @@ async function connectWebSocket() {
|
|
|
2617
2923
|
});
|
|
2618
2924
|
dataWs.on("message", async (data) => {
|
|
2619
2925
|
try {
|
|
2620
|
-
const
|
|
2621
|
-
if (
|
|
2926
|
+
const raw = JSON.parse(data.toString());
|
|
2927
|
+
if (raw.type === "connected")
|
|
2928
|
+
return;
|
|
2929
|
+
// E2EE handshake messages (always plaintext)
|
|
2930
|
+
if (raw.type === "e2ee_hello" && typeof raw.pubkey === "string") {
|
|
2931
|
+
e2eeHandlePeerHello(raw.pubkey, dataWs);
|
|
2932
|
+
return;
|
|
2933
|
+
}
|
|
2934
|
+
if (raw.type === "e2ee_secure_ready") {
|
|
2935
|
+
e2eeHandlePeerReady();
|
|
2936
|
+
return;
|
|
2937
|
+
}
|
|
2938
|
+
// Reconnect request: reset E2EE so fresh handshake happens, then replay
|
|
2939
|
+
if (raw.ns === "system" && raw.action === "reconnect") {
|
|
2940
|
+
e2eeReset();
|
|
2941
|
+
const lastSeq = Number(raw.payload?.lastSeq ?? 0);
|
|
2942
|
+
const toReplay = replayBuffer.filter((e) => e.seq > lastSeq);
|
|
2943
|
+
console.log(`[replay] replaying ${toReplay.length} messages after seq ${lastSeq}`);
|
|
2944
|
+
// Replay without encryption — E2EE handshake hasn't completed yet
|
|
2945
|
+
for (const entry of toReplay)
|
|
2946
|
+
dataWs.send(JSON.stringify(entry.msg));
|
|
2622
2947
|
return;
|
|
2948
|
+
}
|
|
2949
|
+
// Decrypt payload if E2EE is active
|
|
2950
|
+
let message = raw;
|
|
2951
|
+
if (e2eeActive && typeof raw.enc === "string") {
|
|
2952
|
+
try {
|
|
2953
|
+
message = { ...raw, payload: e2eeDecrypt(raw.enc) };
|
|
2954
|
+
delete message.enc;
|
|
2955
|
+
}
|
|
2956
|
+
catch (decErr) {
|
|
2957
|
+
console.error("[e2ee] decryption failed:", decErr.message);
|
|
2958
|
+
return;
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2623
2961
|
if (isProtocolResponse(message)) {
|
|
2624
2962
|
// Ignore server/app responses forwarded over WS; CLI only processes requests.
|
|
2625
2963
|
return;
|
|
@@ -2636,6 +2974,12 @@ async function connectWebSocket() {
|
|
|
2636
2974
|
}
|
|
2637
2975
|
});
|
|
2638
2976
|
dataWs.on("close", (code, reason) => {
|
|
2977
|
+
// Reset backpressure state so reconnect starts fresh
|
|
2978
|
+
dataChannelPaused = false;
|
|
2979
|
+
if (dataChannelDrainTimer) {
|
|
2980
|
+
clearInterval(dataChannelDrainTimer);
|
|
2981
|
+
dataChannelDrainTimer = null;
|
|
2982
|
+
}
|
|
2639
2983
|
if (!settled) {
|
|
2640
2984
|
failConnection(`data close before ready (${code}: ${reason.toString()})`);
|
|
2641
2985
|
return;
|
|
@@ -2687,6 +3031,11 @@ async function main() {
|
|
|
2687
3031
|
if (EXTRA_PORTS.length > 0) {
|
|
2688
3032
|
console.log(`Extra ports enabled: ${EXTRA_PORTS.join(", ")}`);
|
|
2689
3033
|
}
|
|
3034
|
+
// Detect cloud VM mode
|
|
3035
|
+
cloudJobConfig = await readCloudJobConfig();
|
|
3036
|
+
if (cloudJobConfig?.session_code) {
|
|
3037
|
+
console.log(`[cloud] Running in VM mode (sandbox: ${cloudJobConfig.sandbox_id})`);
|
|
3038
|
+
}
|
|
2690
3039
|
try {
|
|
2691
3040
|
console.log("Checking PTY runtime...");
|
|
2692
3041
|
await ensurePtyBinaryReady();
|
|
@@ -2717,13 +3066,26 @@ async function main() {
|
|
|
2717
3066
|
console.log("OpenCode ready.\n");
|
|
2718
3067
|
// Subscribe to OpenCode events
|
|
2719
3068
|
subscribeToOpenCodeEvents(client);
|
|
2720
|
-
|
|
3069
|
+
let session;
|
|
3070
|
+
if (cloudJobConfig?.session_code) {
|
|
3071
|
+
// Cloud mode: connect to an existing session assigned by the manager
|
|
3072
|
+
session = await connectToCloudSession(cloudJobConfig.session_code);
|
|
3073
|
+
console.log(`[cloud] Connected to session ${session.code} via ${session.primary}`);
|
|
3074
|
+
}
|
|
3075
|
+
else {
|
|
3076
|
+
// Public mode: create a new session and show QR code
|
|
3077
|
+
session = await createSessionFromManager();
|
|
3078
|
+
displayQR(normalizeGatewayUrl(session.primary), session.backup ? normalizeGatewayUrl(session.backup) : null, session.code);
|
|
3079
|
+
}
|
|
2721
3080
|
currentPrimaryGateway = normalizeGatewayUrl(session.primary);
|
|
2722
3081
|
currentBackupGateway = session.backup ? normalizeGatewayUrl(session.backup) : null;
|
|
2723
3082
|
currentSessionPassword = session.password;
|
|
2724
3083
|
activeGatewayUrl = currentPrimaryGateway;
|
|
2725
3084
|
currentSessionCode = session.code;
|
|
2726
|
-
|
|
3085
|
+
// Start VM heartbeat in cloud mode (session.password = resumeToken)
|
|
3086
|
+
if (cloudJobConfig?.session_code) {
|
|
3087
|
+
startVmHeartbeat(session.password);
|
|
3088
|
+
}
|
|
2727
3089
|
await connectWebSocket();
|
|
2728
3090
|
}
|
|
2729
3091
|
catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lunel-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.43",
|
|
4
4
|
"author": [
|
|
5
5
|
{
|
|
6
6
|
"name": "Soham Bharambe",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"ws": "^8.18.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
+
"@types/minimatch": "^5.1.2",
|
|
35
36
|
"@types/node": "^20.0.0",
|
|
36
37
|
"@types/qrcode-terminal": "^0.12.2",
|
|
37
38
|
"@types/ws": "^8.5.13",
|