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.
Files changed (2) hide show
  1. package/dist/index.js +412 -50
  2. 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.send(JSON.stringify(msg));
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.send(JSON.stringify(msg));
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.send(JSON.stringify(msg));
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.send(JSON.stringify({
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
- try {
1770
- const events = await client.event.subscribe();
1771
- for await (const raw of events.stream) {
1772
- // OpenCode SSE payload shapes vary across versions:
1773
- // { type, properties, ... }
1774
- // { payload: { type, properties, ... }, directory: "..." }
1775
- const parsed = raw;
1776
- const base = parsed?.payload && typeof parsed.payload === "object"
1777
- ? parsed.payload
1778
- : parsed;
1779
- if (!base || typeof base.type !== "string") {
1780
- console.warn("[sse] Dropped malformed event:", redactSensitive(JSON.stringify(parsed).substring(0, 200)));
1781
- continue;
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
- console.log("[sse]", base.type);
1784
- if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
1785
- const msg = {
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(/^http/, "ws");
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
- dataWs.send(JSON.stringify(response));
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
- if (/^https?:\/\//.test(input))
2431
- return input.replace(/\/+$/, "");
2432
- return `https://${input}`.replace(/\/+$/, "");
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(/^http/, "ws");
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 message = JSON.parse(data.toString());
2621
- if (message.type === "connected")
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
- const session = await createSessionFromManager();
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
- displayQR(currentPrimaryGateway, currentBackupGateway, session.code);
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.40",
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",