lunel-cli 0.1.41 → 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 +382 -43
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -13,6 +13,8 @@ import { createServer, createConnection } from "net";
|
|
|
13
13
|
import { createInterface } from "readline";
|
|
14
14
|
const DEFAULT_PROXY_URL = normalizeGatewayUrl(process.env.LUNEL_PROXY_URL || "https://gateway.lunel.dev");
|
|
15
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
|
|
@@ -2427,7 +2694,13 @@ async function processMessage(message) {
|
|
|
2427
2694
|
}
|
|
2428
2695
|
}
|
|
2429
2696
|
function sendResponseOnData(response, dataWs) {
|
|
2430
|
-
|
|
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
|
+
}
|
|
2431
2704
|
}
|
|
2432
2705
|
function normalizeGatewayUrl(input) {
|
|
2433
2706
|
const raw = input.trim();
|
|
@@ -2461,6 +2734,13 @@ async function createSessionFromManager() {
|
|
|
2461
2734
|
}
|
|
2462
2735
|
return (await response.json());
|
|
2463
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
|
+
}
|
|
2464
2744
|
function displayQR(primaryGateway, backupGateway, code) {
|
|
2465
2745
|
console.log("\n");
|
|
2466
2746
|
qrcode.generate(code, { small: true }, (qr) => {
|
|
@@ -2498,6 +2778,7 @@ function buildWsUrl(gatewayUrl, role, channel) {
|
|
|
2498
2778
|
function gracefulShutdown() {
|
|
2499
2779
|
shuttingDown = true;
|
|
2500
2780
|
console.log("\nShutting down...");
|
|
2781
|
+
stopVmHeartbeat();
|
|
2501
2782
|
stopPortSync();
|
|
2502
2783
|
if (ptyProcess) {
|
|
2503
2784
|
ptyProcess.kill();
|
|
@@ -2575,6 +2856,8 @@ async function connectWebSocket() {
|
|
|
2575
2856
|
if (message.type === "connected")
|
|
2576
2857
|
return;
|
|
2577
2858
|
if (message.type === "session_password" && message.password) {
|
|
2859
|
+
if (!currentSessionPassword)
|
|
2860
|
+
resetReplayBuffer(); // new session
|
|
2578
2861
|
currentSessionPassword = message.password;
|
|
2579
2862
|
console.log("[session] received reconnect password");
|
|
2580
2863
|
return;
|
|
@@ -2640,9 +2923,41 @@ async function connectWebSocket() {
|
|
|
2640
2923
|
});
|
|
2641
2924
|
dataWs.on("message", async (data) => {
|
|
2642
2925
|
try {
|
|
2643
|
-
const
|
|
2644
|
-
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);
|
|
2645
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));
|
|
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
|
+
}
|
|
2646
2961
|
if (isProtocolResponse(message)) {
|
|
2647
2962
|
// Ignore server/app responses forwarded over WS; CLI only processes requests.
|
|
2648
2963
|
return;
|
|
@@ -2659,6 +2974,12 @@ async function connectWebSocket() {
|
|
|
2659
2974
|
}
|
|
2660
2975
|
});
|
|
2661
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
|
+
}
|
|
2662
2983
|
if (!settled) {
|
|
2663
2984
|
failConnection(`data close before ready (${code}: ${reason.toString()})`);
|
|
2664
2985
|
return;
|
|
@@ -2710,6 +3031,11 @@ async function main() {
|
|
|
2710
3031
|
if (EXTRA_PORTS.length > 0) {
|
|
2711
3032
|
console.log(`Extra ports enabled: ${EXTRA_PORTS.join(", ")}`);
|
|
2712
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
|
+
}
|
|
2713
3039
|
try {
|
|
2714
3040
|
console.log("Checking PTY runtime...");
|
|
2715
3041
|
await ensurePtyBinaryReady();
|
|
@@ -2740,13 +3066,26 @@ async function main() {
|
|
|
2740
3066
|
console.log("OpenCode ready.\n");
|
|
2741
3067
|
// Subscribe to OpenCode events
|
|
2742
3068
|
subscribeToOpenCodeEvents(client);
|
|
2743
|
-
|
|
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
|
+
}
|
|
2744
3080
|
currentPrimaryGateway = normalizeGatewayUrl(session.primary);
|
|
2745
3081
|
currentBackupGateway = session.backup ? normalizeGatewayUrl(session.backup) : null;
|
|
2746
3082
|
currentSessionPassword = session.password;
|
|
2747
3083
|
activeGatewayUrl = currentPrimaryGateway;
|
|
2748
3084
|
currentSessionCode = session.code;
|
|
2749
|
-
|
|
3085
|
+
// Start VM heartbeat in cloud mode (session.password = resumeToken)
|
|
3086
|
+
if (cloudJobConfig?.session_code) {
|
|
3087
|
+
startVmHeartbeat(session.password);
|
|
3088
|
+
}
|
|
2750
3089
|
await connectWebSocket();
|
|
2751
3090
|
}
|
|
2752
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",
|