lunel-cli 0.1.41 → 0.1.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,18 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  import { WebSocket } from "ws";
3
3
  import qrcode from "qrcode-terminal";
4
- import { createOpencodeServer, createOpencodeClient } from "@opencode-ai/sdk";
4
+ import { createAiManager } from "./ai/index.js";
5
5
  import * as crypto from "crypto";
6
6
  import Ignore from "ignore";
7
7
  const ignore = Ignore.default;
8
8
  import * as fs from "fs/promises";
9
+ import * as fssync from "fs";
9
10
  import * as path from "path";
10
11
  import * as os from "os";
11
- import { spawn, execSync, execFileSync } from "child_process";
12
+ import { spawn, spawnSync, execSync, execFileSync } from "child_process";
12
13
  import { createServer, createConnection } from "net";
13
14
  import { createInterface } from "readline";
14
15
  const DEFAULT_PROXY_URL = normalizeGatewayUrl(process.env.LUNEL_PROXY_URL || "https://gateway.lunel.dev");
15
16
  const MANAGER_URL = normalizeGatewayUrl(process.env.LUNEL_MANAGER_URL || "https://manager.lunel.dev");
17
+ const CLOUD_JOB_CONFIG_PATH = "/etc/lunel/job.json";
18
+ const VM_HEARTBEAT_INTERVAL_MS = 10_000;
16
19
  const CLI_ARGS = process.argv.slice(2);
17
20
  import { createRequire } from "module";
18
21
  const __require = createRequire(import.meta.url);
@@ -31,8 +34,8 @@ const processes = new Map();
31
34
  const processOutputBuffers = new Map();
32
35
  // CPU usage tracking
33
36
  let lastCpuInfo = null;
34
- // OpenCode client
35
- let opencodeClient = null;
37
+ // AI manager — runs OpenCode and Codex simultaneously, routes by backend
38
+ let aiManager = null;
36
39
  // Proxy tunnel management
37
40
  let currentSessionCode = null;
38
41
  let currentSessionPassword = null;
@@ -44,6 +47,51 @@ let activeControlWs = null;
44
47
  let activeDataWs = null;
45
48
  const activeTunnels = new Map();
46
49
  const PORT_SYNC_INTERVAL_MS = 30_000;
50
+ let cloudJobConfig = null;
51
+ let vmHeartbeatTimer = null;
52
+ async function readCloudJobConfig() {
53
+ try {
54
+ const data = await fs.readFile(CLOUD_JOB_CONFIG_PATH, "utf-8");
55
+ return JSON.parse(data);
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ function startVmHeartbeat(resumeToken) {
62
+ if (!cloudJobConfig?.session_code)
63
+ return;
64
+ if (vmHeartbeatTimer)
65
+ return;
66
+ const cfg = cloudJobConfig;
67
+ const sendHeartbeat = () => {
68
+ if (shuttingDown)
69
+ return;
70
+ fetch(`${MANAGER_URL}/v1/vm/heartbeat`, {
71
+ method: "POST",
72
+ headers: { "Content-Type": "application/json" },
73
+ body: JSON.stringify({
74
+ sandboxId: cfg.sandbox_id,
75
+ resumeToken,
76
+ sandmanUrl: cfg.sandman_url || "",
77
+ repoUrl: cfg.repo_url,
78
+ branch: cfg.branch,
79
+ vmProfile: cfg.vm_profile || "",
80
+ }),
81
+ }).catch((err) => {
82
+ if (!shuttingDown)
83
+ console.warn("[vm] heartbeat failed:", err.message);
84
+ });
85
+ };
86
+ sendHeartbeat();
87
+ vmHeartbeatTimer = setInterval(sendHeartbeat, VM_HEARTBEAT_INTERVAL_MS);
88
+ }
89
+ function stopVmHeartbeat() {
90
+ if (vmHeartbeatTimer) {
91
+ clearInterval(vmHeartbeatTimer);
92
+ vmHeartbeatTimer = null;
93
+ }
94
+ }
47
95
  const CLI_LOCAL_TCP_CONNECT_TIMEOUT_MS = 2_500;
48
96
  const PROXY_WS_CONNECT_TIMEOUT_MS = 12_000;
49
97
  const TUNNEL_SETUP_BUDGET_MS = 18_000;
@@ -220,11 +268,35 @@ function isProtocolResponse(value) {
220
268
  // Path Safety
221
269
  // ============================================================================
222
270
  function resolveSafePath(requestedPath) {
223
- const resolved = path.resolve(ROOT_DIR, requestedPath);
224
- if (!resolved.startsWith(ROOT_DIR)) {
271
+ // path.resolve handles ".." components, but on case-insensitive or symlinked
272
+ // filesystems a simple startsWith check can still be bypassed. We use
273
+ // realpathSync to canonicalise the path (resolves symlinks, normalises case on
274
+ // Windows) before comparing against ROOT_DIR, which is itself canonicalised at
275
+ // startup. If the path does not exist yet we fall back to the lexical resolve so
276
+ // that callers creating new files can still pass the check.
277
+ const lexical = path.resolve(ROOT_DIR, requestedPath);
278
+ let canonical;
279
+ try {
280
+ canonical = fssync.realpathSync(lexical);
281
+ }
282
+ catch {
283
+ // Path doesn't exist yet — verify lexically. Still safe because path.resolve
284
+ // already eliminated all ".." traversals in the resolved string.
285
+ canonical = lexical;
286
+ }
287
+ // Ensure ROOT_DIR itself is canonical for a reliable prefix comparison.
288
+ const canonicalRoot = (() => {
289
+ try {
290
+ return fssync.realpathSync(ROOT_DIR);
291
+ }
292
+ catch {
293
+ return ROOT_DIR;
294
+ }
295
+ })();
296
+ if (!canonical.startsWith(canonicalRoot + path.sep) && canonical !== canonicalRoot) {
225
297
  return null;
226
298
  }
227
- return resolved;
299
+ return canonical;
228
300
  }
229
301
  function assertSafePath(requestedPath) {
230
302
  const safePath = resolveSafePath(requestedPath);
@@ -781,6 +853,167 @@ async function handleGitDiscard(payload) {
781
853
  // Terminal Handlers (delegates to Rust PTY binary)
782
854
  // ============================================================================
783
855
  let dataChannel = null;
856
+ let e2eeKeyPair = null;
857
+ let e2eeCliToAppKey = null; // AES-256-GCM key, CLI→App direction
858
+ let e2eeAppToCliKey = null; // AES-256-GCM key, App→CLI direction
859
+ let e2eeActive = false;
860
+ let e2eeSentReady = false;
861
+ let e2eeGotReady = false;
862
+ // No outbound counter — we generate a full 96-bit random nonce per message.
863
+ // AES-GCM nonce uniqueness is guaranteed by CSPRNG with negligible collision
864
+ // probability (birthday bound: ~2^48 messages before 2^-32 collision risk).
865
+ function e2eeReset() {
866
+ e2eeKeyPair = null;
867
+ e2eeCliToAppKey = null;
868
+ e2eeAppToCliKey = null;
869
+ e2eeActive = false;
870
+ e2eeSentReady = false;
871
+ e2eeGotReady = false;
872
+ }
873
+ function e2eeHandlePeerHello(peerPubkeyB64, dataWs) {
874
+ const kp = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
875
+ e2eeKeyPair = kp;
876
+ const peerDer = Buffer.from(peerPubkeyB64, "base64url");
877
+ const peerPubKey = crypto.createPublicKey({ key: peerDer, type: "spki", format: "der" });
878
+ const sharedSecret = crypto.diffieHellman({ privateKey: kp.privateKey, publicKey: peerPubKey });
879
+ const salt = Buffer.alloc(32);
880
+ e2eeCliToAppKey = Buffer.from(crypto.hkdfSync("sha256", sharedSecret, salt, Buffer.from("lunel-cli-to-app"), 32));
881
+ e2eeAppToCliKey = Buffer.from(crypto.hkdfSync("sha256", sharedSecret, salt, Buffer.from("lunel-app-to-cli"), 32));
882
+ // Send our own pubkey so the app can derive the same shared secret
883
+ const mySpki = kp.publicKey.export({ type: "spki", format: "der" });
884
+ dataWs.send(JSON.stringify({ type: "e2ee_hello", pubkey: Buffer.from(mySpki).toString("base64url") }));
885
+ dataWs.send(JSON.stringify({ type: "e2ee_secure_ready" }));
886
+ e2eeSentReady = true;
887
+ if (e2eeGotReady) {
888
+ e2eeActive = true;
889
+ console.log("[e2ee] encryption active");
890
+ }
891
+ }
892
+ function e2eeHandlePeerReady() {
893
+ e2eeGotReady = true;
894
+ if (e2eeSentReady) {
895
+ e2eeActive = true;
896
+ console.log("[e2ee] encryption active");
897
+ }
898
+ }
899
+ function e2eeEncrypt(payload) {
900
+ if (!e2eeCliToAppKey)
901
+ throw new Error("[e2ee] CLI→App key not ready");
902
+ // Full 96-bit random nonce — safe across reconnects with no counter state.
903
+ const nonce = crypto.randomBytes(12);
904
+ const plain = Buffer.from(JSON.stringify(payload));
905
+ const cipher = crypto.createCipheriv("aes-256-gcm", e2eeCliToAppKey, nonce);
906
+ const ct = Buffer.concat([cipher.update(plain), cipher.final()]);
907
+ const tag = cipher.getAuthTag();
908
+ // wire: nonce(12) || ct || tag(16) — consistent with WebCrypto AES-GCM format on app side
909
+ return Buffer.concat([nonce, ct, tag]).toString("base64url");
910
+ }
911
+ function e2eeDecrypt(enc) {
912
+ if (!e2eeAppToCliKey)
913
+ throw new Error("[e2ee] App→CLI key not ready");
914
+ const buf = Buffer.from(enc, "base64url");
915
+ const nonce = buf.subarray(0, 12);
916
+ const ct = buf.subarray(12, buf.length - 16);
917
+ const tag = buf.subarray(buf.length - 16);
918
+ const decipher = crypto.createDecipheriv("aes-256-gcm", e2eeAppToCliKey, nonce);
919
+ decipher.setAuthTag(tag);
920
+ return JSON.parse(Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf-8"));
921
+ }
922
+ // ============================================================================
923
+ // Replay buffer + backpressure
924
+ // ============================================================================
925
+ // Replay buffer for sequenced outbound data-channel messages
926
+ let outboundSeq = 0;
927
+ let lastSentSeq = 0; // highest seq actually flushed to the socket (not paused)
928
+ const REPLAY_BUFFER_MAX_MESSAGES = 500;
929
+ const REPLAY_BUFFER_MAX_BYTES = 10 * 1024 * 1024; // 10 MB
930
+ const replayBuffer = [];
931
+ let replayBufferBytes = 0;
932
+ // Data-channel backpressure
933
+ const DATA_CHANNEL_HIGH_WATER_BYTES = 1 * 1024 * 1024; // 1 MB — pause forwarding
934
+ const DATA_CHANNEL_LOW_WATER_BYTES = 256 * 1024; // 256 KB — resume forwarding
935
+ let dataChannelPaused = false;
936
+ let dataChannelDrainTimer = null;
937
+ function sendOnDataChannel(ws, msg) {
938
+ if (e2eeActive && e2eeCliToAppKey) {
939
+ const { payload, ...envelope } = msg;
940
+ ws.send(JSON.stringify({ ...envelope, enc: e2eeEncrypt(payload) }));
941
+ }
942
+ else {
943
+ ws.send(JSON.stringify(msg));
944
+ }
945
+ }
946
+ function sendSequenced(channel, msg) {
947
+ const seq = ++outboundSeq;
948
+ const msgWithSeq = { ...msg, seq };
949
+ const byteLen = Buffer.byteLength(JSON.stringify(msgWithSeq), "utf-8");
950
+ replayBuffer.push({ seq, msg: msgWithSeq, byteLen });
951
+ replayBufferBytes += byteLen;
952
+ while (replayBuffer.length > REPLAY_BUFFER_MAX_MESSAGES || replayBufferBytes > REPLAY_BUFFER_MAX_BYTES) {
953
+ const evicted = replayBuffer.shift();
954
+ if (evicted)
955
+ replayBufferBytes -= evicted.byteLen;
956
+ }
957
+ // Skip the actual send while backpressured — event is already in replay buffer
958
+ if (channel.readyState === WebSocket.OPEN && !dataChannelPaused) {
959
+ sendOnDataChannel(channel, msgWithSeq);
960
+ lastSentSeq = seq;
961
+ }
962
+ }
963
+ function resetReplayBuffer() {
964
+ outboundSeq = 0;
965
+ lastSentSeq = 0;
966
+ replayBuffer.length = 0;
967
+ replayBufferBytes = 0;
968
+ dataChannelPaused = false;
969
+ e2eeReset();
970
+ if (dataChannelDrainTimer) {
971
+ clearInterval(dataChannelDrainTimer);
972
+ dataChannelDrainTimer = null;
973
+ }
974
+ }
975
+ function resumeDataChannel() {
976
+ if (!dataChannelPaused)
977
+ return;
978
+ dataChannelPaused = false;
979
+ if (dataChannelDrainTimer) {
980
+ clearInterval(dataChannelDrainTimer);
981
+ dataChannelDrainTimer = null;
982
+ }
983
+ // Flush events that were buffered during the pause (re-encrypts with current key)
984
+ const toFlush = replayBuffer.filter((e) => e.seq > lastSentSeq);
985
+ if (dataChannel?.readyState === WebSocket.OPEN) {
986
+ for (const entry of toFlush) {
987
+ sendOnDataChannel(dataChannel, entry.msg);
988
+ lastSentSeq = entry.seq;
989
+ }
990
+ }
991
+ console.log(`[backpressure] data channel resumed, flushed ${toFlush.length} buffered events`);
992
+ if (activeControlWs?.readyState === WebSocket.OPEN) {
993
+ const buffered = dataChannel?.bufferedAmount ?? 0;
994
+ activeControlWs.send(JSON.stringify({ type: "data_channel_resumed", bufferedBytes: buffered }));
995
+ }
996
+ }
997
+ function checkDataChannelBackpressure() {
998
+ if (!dataChannel || dataChannel.readyState !== WebSocket.OPEN)
999
+ return;
1000
+ const buffered = dataChannel.bufferedAmount ?? 0;
1001
+ if (!dataChannelPaused && buffered > DATA_CHANNEL_HIGH_WATER_BYTES) {
1002
+ dataChannelPaused = true;
1003
+ console.warn(`[backpressure] data channel paused (${buffered} bytes buffered)`);
1004
+ if (activeControlWs?.readyState === WebSocket.OPEN) {
1005
+ activeControlWs.send(JSON.stringify({ type: "data_channel_paused", bufferedBytes: buffered }));
1006
+ }
1007
+ if (!dataChannelDrainTimer) {
1008
+ dataChannelDrainTimer = setInterval(() => {
1009
+ const current = dataChannel?.bufferedAmount ?? 0;
1010
+ if (current < DATA_CHANNEL_LOW_WATER_BYTES || !dataChannel || dataChannel.readyState !== WebSocket.OPEN) {
1011
+ resumeDataChannel();
1012
+ }
1013
+ }, 100);
1014
+ }
1015
+ }
1016
+ }
784
1017
  let ensurePtyBinaryPromise = null;
785
1018
  function normalizeJsonWithTrailingCommas(text) {
786
1019
  return text.replace(/,\s*([}\]])/g, "$1");
@@ -991,7 +1224,7 @@ async function ensurePtyProcess() {
991
1224
  scrollbackLength: event.scrollbackLength,
992
1225
  },
993
1226
  };
994
- dataChannel.send(JSON.stringify(msg));
1227
+ sendSequenced(dataChannel, msg);
995
1228
  }
996
1229
  }
997
1230
  else if (event.event === "exit") {
@@ -1004,7 +1237,7 @@ async function ensurePtyProcess() {
1004
1237
  action: "exit",
1005
1238
  payload: { terminalId: event.id, code: event.code },
1006
1239
  };
1007
- dataChannel.send(JSON.stringify(msg));
1240
+ sendSequenced(dataChannel, msg);
1008
1241
  }
1009
1242
  }
1010
1243
  else if (event.event === "error") {
@@ -1126,7 +1359,7 @@ function handleProcessesSpawn(payload) {
1126
1359
  cwd: workDir,
1127
1360
  env: { ...process.env, ...extraEnv },
1128
1361
  stdio: ["pipe", "pipe", "pipe"],
1129
- shell: true,
1362
+ shell: false,
1130
1363
  });
1131
1364
  const pid = proc.pid;
1132
1365
  const channel = `proc-${pid}`;
@@ -1155,7 +1388,7 @@ function handleProcessesSpawn(payload) {
1155
1388
  action: "output",
1156
1389
  payload: { pid, channel, stream, data: text },
1157
1390
  };
1158
- dataChannel.send(JSON.stringify(msg));
1391
+ sendSequenced(dataChannel, msg);
1159
1392
  }
1160
1393
  };
1161
1394
  proc.stdout?.on("data", sendOutput("stdout"));
@@ -1269,9 +1502,10 @@ function handlePortsList() {
1269
1502
  return { ports };
1270
1503
  }
1271
1504
  function handlePortsIsAvailable(payload) {
1272
- const port = payload.port;
1273
- if (!port)
1274
- throw Object.assign(new Error("port is required"), { code: "EINVAL" });
1505
+ const port = Math.floor(Number(payload.port));
1506
+ if (!Number.isFinite(port) || port < 1 || port > 65535) {
1507
+ throw Object.assign(new Error("port must be an integer between 1 and 65535"), { code: "EINVAL" });
1508
+ }
1275
1509
  return new Promise((resolve) => {
1276
1510
  const server = createServer();
1277
1511
  server.once("error", (err) => {
@@ -1294,33 +1528,58 @@ function handlePortsKill(payload) {
1294
1528
  const port = payload.port;
1295
1529
  if (!port)
1296
1530
  throw Object.assign(new Error("port is required"), { code: "EINVAL" });
1531
+ // Strict port range validation to prevent injection via crafted numeric values
1532
+ const portNum = Math.floor(Number(port));
1533
+ if (!Number.isFinite(portNum) || portNum < 1 || portNum > 65535) {
1534
+ throw Object.assign(new Error("port must be an integer between 1 and 65535"), { code: "EINVAL" });
1535
+ }
1297
1536
  const platform = os.platform();
1298
1537
  try {
1299
1538
  let pid = null;
1300
1539
  if (platform === "darwin" || platform === "linux") {
1301
- const output = execSync(`lsof -ti:${port} 2>/dev/null || true`, { encoding: "utf-8" });
1302
- const pids = output.trim().split("\n").filter(Boolean);
1303
- if (pids.length > 0) {
1304
- pid = parseInt(pids[0]);
1305
- execSync(`kill -9 ${pids.join(" ")}`, { encoding: "utf-8" });
1540
+ // Use spawnSync with an explicit args array never shell: true — so portNum
1541
+ // cannot escape into a shell command even if it were somehow non-numeric.
1542
+ const result = spawnSync("lsof", ["-ti", String(portNum)], { encoding: "utf-8" });
1543
+ const pids = (result.stdout || "").trim().split("\n").filter(Boolean);
1544
+ for (const pidStr of pids) {
1545
+ const p = parseInt(pidStr, 10);
1546
+ if (!Number.isFinite(p) || p <= 0)
1547
+ continue;
1548
+ if (pid === null)
1549
+ pid = p;
1550
+ // Send SIGKILL directly via process.kill — no shell involved.
1551
+ try {
1552
+ process.kill(p, "SIGKILL");
1553
+ }
1554
+ catch { /* already dead */ }
1306
1555
  }
1307
1556
  }
1308
1557
  else if (platform === "win32") {
1309
- const output = execSync(`netstat -ano | findstr :${port}`, { encoding: "utf-8" });
1310
- const lines = output.trim().split("\n");
1558
+ // Use netstat via args array, parse PIDs, then taskkill via args array.
1559
+ const result = spawnSync("netstat", ["-ano"], { encoding: "utf-8" });
1560
+ const lines = (result.stdout || "").trim().split("\n");
1311
1561
  for (const line of lines) {
1312
1562
  const parts = line.trim().split(/\s+/);
1313
- if (parts.length >= 5) {
1314
- pid = parseInt(parts[4]);
1315
- execSync(`taskkill /F /PID ${pid}`, { encoding: "utf-8" });
1316
- break;
1317
- }
1563
+ // netstat -ano columns: Proto Local Foreign State PID
1564
+ // Match only lines where the local address ends with :<portNum>
1565
+ if (parts.length < 5)
1566
+ continue;
1567
+ const localAddr = parts[1] ?? "";
1568
+ if (!localAddr.endsWith(`:${portNum}`))
1569
+ continue;
1570
+ const p = parseInt(parts[4], 10);
1571
+ if (!Number.isFinite(p) || p <= 0)
1572
+ continue;
1573
+ if (pid === null)
1574
+ pid = p;
1575
+ spawnSync("taskkill", ["/F", "/PID", String(p)], { encoding: "utf-8" });
1576
+ break;
1318
1577
  }
1319
1578
  }
1320
- return { port, pid };
1579
+ return { port: portNum, pid };
1321
1580
  }
1322
1581
  catch (err) {
1323
- throw Object.assign(new Error(`Failed to kill process on port ${port}`), { code: "EPERM" });
1582
+ throw Object.assign(new Error(`Failed to kill process on port ${portNum}`), { code: "EPERM" });
1324
1583
  }
1325
1584
  }
1326
1585
  // ============================================================================
@@ -1552,258 +1811,9 @@ async function handleHttpRequest(payload) {
1552
1811
  }
1553
1812
  }
1554
1813
  // ============================================================================
1555
- // AI Handlers (OpenCode SDK)
1814
+ // AI Handlers delegated to the active AIProvider
1556
1815
  // ============================================================================
1557
- function requireData(response, label) {
1558
- if (!response.data) {
1559
- const errMsg = response.error
1560
- ? (typeof response.error === "string" ? response.error : JSON.stringify(response.error))
1561
- : `${label} returned no data`;
1562
- console.error(`[ai] ${label} failed:`, redactSensitive(errMsg), "raw response:", redactSensitive(JSON.stringify(response).substring(0, 500)));
1563
- throw new Error(errMsg);
1564
- }
1565
- return response.data;
1566
- }
1567
- async function handleAiCreateSession(payload) {
1568
- const title = payload.title || undefined;
1569
- if (VERBOSE_AI_LOGS)
1570
- console.log("[ai] createSession called");
1571
- try {
1572
- const response = await opencodeClient.session.create({ body: { title } });
1573
- if (VERBOSE_AI_LOGS) {
1574
- console.log("[ai] createSession response ok:", !!response.data, "error:", response.error ? redactSensitive(JSON.stringify(response.error).substring(0, 200)) : "none");
1575
- }
1576
- return { session: requireData(response, "session.create") };
1577
- }
1578
- catch (err) {
1579
- console.error("[ai] createSession exception:", redactSensitive(err.message));
1580
- throw err;
1581
- }
1582
- }
1583
- async function handleAiListSessions() {
1584
- if (VERBOSE_AI_LOGS)
1585
- console.log("[ai] listSessions called");
1586
- try {
1587
- const response = await opencodeClient.session.list();
1588
- const data = requireData(response, "session.list");
1589
- if (VERBOSE_AI_LOGS) {
1590
- console.log("[ai] listSessions returned", Array.isArray(data) ? data.length : typeof data, "sessions");
1591
- }
1592
- return { sessions: data };
1593
- }
1594
- catch (err) {
1595
- console.error("[ai] listSessions exception:", err.message);
1596
- throw err;
1597
- }
1598
- }
1599
- async function handleAiGetSession(payload) {
1600
- const id = payload.id;
1601
- const response = await opencodeClient.session.get({ path: { id } });
1602
- return { session: requireData(response, "session.get") };
1603
- }
1604
- async function handleAiDeleteSession(payload) {
1605
- const id = payload.id;
1606
- const response = await opencodeClient.session.delete({ path: { id } });
1607
- if (response.error)
1608
- throw new Error(JSON.stringify(response.error));
1609
- return {};
1610
- }
1611
- async function handleAiGetMessages(payload) {
1612
- const id = payload.id;
1613
- if (VERBOSE_AI_LOGS)
1614
- console.log("[ai] getMessages called");
1615
- try {
1616
- const response = await opencodeClient.session.messages({ path: { id } });
1617
- const raw = requireData(response, "session.messages");
1618
- // Transform SDK shape { info: { id, sessionID, role, ... }, parts: [...] }
1619
- // into app shape { id, role, parts }
1620
- const messages = raw.map((m) => ({
1621
- id: m.info.id,
1622
- role: m.info.role,
1623
- parts: m.parts || [],
1624
- time: m.info.time,
1625
- }));
1626
- if (VERBOSE_AI_LOGS)
1627
- console.log("[ai] getMessages returned", messages.length, "messages");
1628
- return { messages };
1629
- }
1630
- catch (err) {
1631
- console.error("[ai] getMessages exception:", err.message);
1632
- throw err;
1633
- }
1634
- }
1635
- async function handleAiPrompt(payload) {
1636
- const sessionId = payload.sessionId;
1637
- const text = payload.text;
1638
- const model = payload.model;
1639
- const agent = payload.agent;
1640
- if (VERBOSE_AI_LOGS) {
1641
- console.log("[ai] prompt called", {
1642
- hasSessionId: Boolean(sessionId),
1643
- model: redactSensitive(JSON.stringify(model || {})),
1644
- hasAgent: Boolean(agent),
1645
- textLength: typeof text === "string" ? text.length : 0,
1646
- });
1647
- }
1648
- // Fire and forget — results stream via SSE events forwarded on data channel
1649
- opencodeClient.session.prompt({
1650
- path: { id: sessionId },
1651
- body: {
1652
- parts: [{ type: "text", text }],
1653
- ...(model ? { model } : {}),
1654
- ...(agent ? { agent } : {}),
1655
- },
1656
- }).catch((err) => {
1657
- console.error("[ai] prompt error:", err.message);
1658
- if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
1659
- dataChannel.send(JSON.stringify({
1660
- v: 1,
1661
- id: `evt-${Date.now()}`,
1662
- ns: "ai",
1663
- action: "event",
1664
- payload: {
1665
- type: "prompt_error",
1666
- properties: { sessionId, error: err.message },
1667
- },
1668
- }));
1669
- }
1670
- });
1671
- return { ack: true };
1672
- }
1673
- async function handleAiAbort(payload) {
1674
- const id = payload.sessionId;
1675
- await opencodeClient.session.abort({ path: { id } });
1676
- return {};
1677
- }
1678
- async function handleAiAgents() {
1679
- if (VERBOSE_AI_LOGS)
1680
- console.log("[ai] getAgents called");
1681
- try {
1682
- const response = await opencodeClient.app.agents();
1683
- const data = requireData(response, "app.agents");
1684
- if (VERBOSE_AI_LOGS) {
1685
- console.log("[ai] getAgents returned:", redactSensitive(JSON.stringify(data).substring(0, 300)));
1686
- }
1687
- return { agents: data };
1688
- }
1689
- catch (err) {
1690
- console.error("[ai] getAgents exception:", err.message);
1691
- throw err;
1692
- }
1693
- }
1694
- async function handleAiProviders() {
1695
- if (VERBOSE_AI_LOGS)
1696
- console.log("[ai] getProviders called");
1697
- try {
1698
- const response = await opencodeClient.config.providers();
1699
- const data = requireData(response, "config.providers");
1700
- if (VERBOSE_AI_LOGS) {
1701
- console.log("[ai] getProviders returned", data.providers?.length, "providers, defaults:", redactSensitive(JSON.stringify(data.default)));
1702
- }
1703
- return { providers: data.providers, default: data.default };
1704
- }
1705
- catch (err) {
1706
- console.error("[ai] getProviders exception:", err.message);
1707
- throw err;
1708
- }
1709
- }
1710
- async function handleAiSetAuth(payload) {
1711
- const providerId = payload.providerId;
1712
- const key = payload.key;
1713
- await opencodeClient.auth.set({
1714
- path: { id: providerId },
1715
- body: { type: "api", key },
1716
- });
1717
- return {};
1718
- }
1719
- async function handleAiCommand(payload) {
1720
- const sessionId = payload.sessionId;
1721
- const command = payload.command;
1722
- const args = payload.arguments || "";
1723
- const response = await opencodeClient.session.command({
1724
- path: { id: sessionId },
1725
- body: { command, arguments: args },
1726
- });
1727
- return { result: response.data ?? null };
1728
- }
1729
- async function handleAiRevert(payload) {
1730
- const sessionId = payload.sessionId;
1731
- const messageId = payload.messageId;
1732
- await opencodeClient.session.revert({
1733
- path: { id: sessionId },
1734
- body: { messageID: messageId },
1735
- });
1736
- return {};
1737
- }
1738
- async function handleAiUnrevert(payload) {
1739
- const sessionId = payload.sessionId;
1740
- await opencodeClient.session.unrevert({ path: { id: sessionId } });
1741
- return {};
1742
- }
1743
- async function handleAiShare(payload) {
1744
- const sessionId = payload.sessionId;
1745
- const response = await opencodeClient.session.share({ path: { id: sessionId } });
1746
- return { share: requireData(response, "session.share") };
1747
- }
1748
- async function handleAiPermissionReply(payload) {
1749
- const permissionId = payload.permissionId;
1750
- const sessionId = payload.sessionId;
1751
- const response = payload.response;
1752
- const approved = payload.approved;
1753
- // Support new "once" | "always" | "reject" format, fallback to legacy boolean
1754
- let permResponse;
1755
- if (response === "once" || response === "always" || response === "reject") {
1756
- permResponse = response;
1757
- }
1758
- else {
1759
- permResponse = approved ? "once" : "reject";
1760
- }
1761
- await opencodeClient.postSessionIdPermissionsPermissionId({
1762
- path: { id: sessionId, permissionID: permissionId },
1763
- body: { response: permResponse },
1764
- });
1765
- return {};
1766
- }
1767
- // SSE event forwarding from OpenCode to mobile app
1768
- 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;
1782
- }
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));
1796
- }
1797
- }
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
- }
1806
- }
1816
+ // (Implementation lives in cli/src/ai/opencode.ts or cli/src/ai/codex.ts)
1807
1817
  // Proxy Handlers
1808
1818
  // ============================================================================
1809
1819
  async function scanDevPorts() {
@@ -2344,57 +2354,67 @@ async function processMessage(message) {
2344
2354
  throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
2345
2355
  }
2346
2356
  break;
2347
- case "ai":
2357
+ case "ai": {
2358
+ if (!aiManager)
2359
+ throw Object.assign(new Error("AI manager not initialized"), { code: "EUNAVAILABLE" });
2360
+ const backend = (payload.backend === "codex" ? "codex" : "opencode");
2348
2361
  switch (action) {
2362
+ case "backends":
2363
+ result = { backends: aiManager.availableBackends() };
2364
+ break;
2349
2365
  case "prompt":
2350
- result = await handleAiPrompt(payload);
2366
+ result = await aiManager.prompt(backend, payload.sessionId, payload.text, payload.model, payload.agent);
2351
2367
  break;
2352
2368
  case "createSession":
2353
- result = await handleAiCreateSession(payload);
2369
+ result = await aiManager.createSession(backend, payload.title);
2354
2370
  break;
2355
2371
  case "listSessions":
2356
- result = await handleAiListSessions();
2372
+ result = await aiManager.listAllSessions();
2357
2373
  break;
2358
2374
  case "getSession":
2359
- result = await handleAiGetSession(payload);
2375
+ result = await aiManager.getSession(backend, payload.id);
2360
2376
  break;
2361
2377
  case "deleteSession":
2362
- result = await handleAiDeleteSession(payload);
2378
+ result = await aiManager.deleteSession(backend, payload.id);
2363
2379
  break;
2364
2380
  case "getMessages":
2365
- result = await handleAiGetMessages(payload);
2381
+ result = await aiManager.getMessages(backend, payload.id);
2366
2382
  break;
2367
2383
  case "abort":
2368
- result = await handleAiAbort(payload);
2384
+ result = await aiManager.abort(backend, payload.sessionId);
2369
2385
  break;
2370
2386
  case "agents":
2371
- result = await handleAiAgents();
2387
+ result = await aiManager.agents(backend);
2372
2388
  break;
2373
2389
  case "providers":
2374
- result = await handleAiProviders();
2390
+ result = await aiManager.providers(backend);
2375
2391
  break;
2376
2392
  case "setAuth":
2377
- result = await handleAiSetAuth(payload);
2393
+ result = await aiManager.setAuth(backend, payload.providerId, payload.key);
2378
2394
  break;
2379
2395
  case "command":
2380
- result = await handleAiCommand(payload);
2396
+ result = await aiManager.command(backend, payload.sessionId, payload.command, payload.arguments || "");
2381
2397
  break;
2382
2398
  case "revert":
2383
- result = await handleAiRevert(payload);
2399
+ result = await aiManager.revert(backend, payload.sessionId, payload.messageId);
2384
2400
  break;
2385
2401
  case "unrevert":
2386
- result = await handleAiUnrevert(payload);
2402
+ result = await aiManager.unrevert(backend, payload.sessionId);
2387
2403
  break;
2388
2404
  case "share":
2389
- result = await handleAiShare(payload);
2405
+ result = await aiManager.share(backend, payload.sessionId);
2390
2406
  break;
2391
- case "permission":
2392
- result = await handleAiPermissionReply(payload);
2407
+ case "permission": {
2408
+ const r = payload.response;
2409
+ const permResp = r === "once" || r === "always" || r === "reject" ? r : (payload.approved ? "once" : "reject");
2410
+ result = await aiManager.permissionReply(backend, payload.sessionId, payload.permissionId, permResp);
2393
2411
  break;
2412
+ }
2394
2413
  default:
2395
2414
  throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
2396
2415
  }
2397
2416
  break;
2417
+ }
2398
2418
  case "proxy":
2399
2419
  switch (action) {
2400
2420
  case "connect":
@@ -2427,7 +2447,13 @@ async function processMessage(message) {
2427
2447
  }
2428
2448
  }
2429
2449
  function sendResponseOnData(response, dataWs) {
2430
- dataWs.send(JSON.stringify(response));
2450
+ if (e2eeActive && e2eeCliToAppKey) {
2451
+ const { payload, ...envelope } = response;
2452
+ dataWs.send(JSON.stringify({ ...envelope, enc: e2eeEncrypt(payload) }));
2453
+ }
2454
+ else {
2455
+ dataWs.send(JSON.stringify(response));
2456
+ }
2431
2457
  }
2432
2458
  function normalizeGatewayUrl(input) {
2433
2459
  const raw = input.trim();
@@ -2461,6 +2487,13 @@ async function createSessionFromManager() {
2461
2487
  }
2462
2488
  return (await response.json());
2463
2489
  }
2490
+ async function connectToCloudSession(sessionCode) {
2491
+ const response = await fetch(`${MANAGER_URL}/v1/session/${sessionCode}`);
2492
+ if (!response.ok) {
2493
+ throw new Error(`Failed to look up cloud session ${sessionCode}: ${response.status}`);
2494
+ }
2495
+ return (await response.json());
2496
+ }
2464
2497
  function displayQR(primaryGateway, backupGateway, code) {
2465
2498
  console.log("\n");
2466
2499
  qrcode.generate(code, { small: true }, (qr) => {
@@ -2498,6 +2531,8 @@ function buildWsUrl(gatewayUrl, role, channel) {
2498
2531
  function gracefulShutdown() {
2499
2532
  shuttingDown = true;
2500
2533
  console.log("\nShutting down...");
2534
+ void aiManager?.destroy();
2535
+ stopVmHeartbeat();
2501
2536
  stopPortSync();
2502
2537
  if (ptyProcess) {
2503
2538
  ptyProcess.kill();
@@ -2575,6 +2610,8 @@ async function connectWebSocket() {
2575
2610
  if (message.type === "connected")
2576
2611
  return;
2577
2612
  if (message.type === "session_password" && message.password) {
2613
+ if (!currentSessionPassword)
2614
+ resetReplayBuffer(); // new session
2578
2615
  currentSessionPassword = message.password;
2579
2616
  console.log("[session] received reconnect password");
2580
2617
  return;
@@ -2640,9 +2677,41 @@ async function connectWebSocket() {
2640
2677
  });
2641
2678
  dataWs.on("message", async (data) => {
2642
2679
  try {
2643
- const message = JSON.parse(data.toString());
2644
- if (message.type === "connected")
2680
+ const raw = JSON.parse(data.toString());
2681
+ if (raw.type === "connected")
2682
+ return;
2683
+ // E2EE handshake messages (always plaintext)
2684
+ if (raw.type === "e2ee_hello" && typeof raw.pubkey === "string") {
2685
+ e2eeHandlePeerHello(raw.pubkey, dataWs);
2686
+ return;
2687
+ }
2688
+ if (raw.type === "e2ee_secure_ready") {
2689
+ e2eeHandlePeerReady();
2690
+ return;
2691
+ }
2692
+ // Reconnect request: reset E2EE so fresh handshake happens, then replay
2693
+ if (raw.ns === "system" && raw.action === "reconnect") {
2694
+ e2eeReset();
2695
+ const lastSeq = Number(raw.payload?.lastSeq ?? 0);
2696
+ const toReplay = replayBuffer.filter((e) => e.seq > lastSeq);
2697
+ console.log(`[replay] replaying ${toReplay.length} messages after seq ${lastSeq}`);
2698
+ // Replay without encryption — E2EE handshake hasn't completed yet
2699
+ for (const entry of toReplay)
2700
+ dataWs.send(JSON.stringify(entry.msg));
2645
2701
  return;
2702
+ }
2703
+ // Decrypt payload if E2EE is active
2704
+ let message = raw;
2705
+ if (e2eeActive && typeof raw.enc === "string") {
2706
+ try {
2707
+ message = { ...raw, payload: e2eeDecrypt(raw.enc) };
2708
+ delete message.enc;
2709
+ }
2710
+ catch (decErr) {
2711
+ console.error("[e2ee] decryption failed:", decErr.message);
2712
+ return;
2713
+ }
2714
+ }
2646
2715
  if (isProtocolResponse(message)) {
2647
2716
  // Ignore server/app responses forwarded over WS; CLI only processes requests.
2648
2717
  return;
@@ -2659,6 +2728,12 @@ async function connectWebSocket() {
2659
2728
  }
2660
2729
  });
2661
2730
  dataWs.on("close", (code, reason) => {
2731
+ // Reset backpressure state so reconnect starts fresh
2732
+ dataChannelPaused = false;
2733
+ if (dataChannelDrainTimer) {
2734
+ clearInterval(dataChannelDrainTimer);
2735
+ dataChannelDrainTimer = null;
2736
+ }
2662
2737
  if (!settled) {
2663
2738
  failConnection(`data close before ready (${code}: ${reason.toString()})`);
2664
2739
  return;
@@ -2710,43 +2785,50 @@ async function main() {
2710
2785
  if (EXTRA_PORTS.length > 0) {
2711
2786
  console.log(`Extra ports enabled: ${EXTRA_PORTS.join(", ")}`);
2712
2787
  }
2788
+ // Detect cloud VM mode
2789
+ cloudJobConfig = await readCloudJobConfig();
2790
+ if (cloudJobConfig?.session_code) {
2791
+ console.log(`[cloud] Running in VM mode (sandbox: ${cloudJobConfig.sandbox_id})`);
2792
+ }
2713
2793
  try {
2714
2794
  console.log("Checking PTY runtime...");
2715
2795
  await ensurePtyBinaryReady();
2716
2796
  console.log("PTY runtime ready.\n");
2717
- // Generate auth credentials (like CodeNomad does)
2718
- const opencodeUsername = "lunel";
2719
- const opencodePassword = crypto.randomBytes(32).toString("base64url");
2720
- const authHeader = `Basic ${Buffer.from(`${opencodeUsername}:${opencodePassword}`).toString("base64")}`;
2721
- // Set auth env vars BEFORE spawning opencode
2722
- process.env.OPENCODE_SERVER_USERNAME = opencodeUsername;
2723
- process.env.OPENCODE_SERVER_PASSWORD = opencodePassword;
2724
- // Start OpenCode server with random port (like CodeNomad: --port 0)
2725
- console.log("Starting OpenCode...");
2726
- const server = await createOpencodeServer({
2727
- hostname: "127.0.0.1",
2728
- port: 0,
2729
- timeout: 15000,
2730
- });
2731
- console.log(`OpenCode server listening on ${server.url}`);
2732
- // Create client with auth headers
2733
- const client = createOpencodeClient({
2734
- baseUrl: server.url,
2735
- headers: {
2736
- "Authorization": authHeader,
2737
- },
2797
+ // Start both AI backends (OpenCode + Codex). Unavailable ones are skipped.
2798
+ aiManager = await createAiManager();
2799
+ // Wire provider events → mobile app data channel, tagged with backend name.
2800
+ aiManager.subscribe((backend, event) => {
2801
+ if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
2802
+ sendSequenced(dataChannel, {
2803
+ v: 1,
2804
+ id: `evt-${Date.now()}`,
2805
+ ns: "ai",
2806
+ action: "event",
2807
+ payload: { ...event, backend },
2808
+ });
2809
+ checkDataChannelBackpressure();
2810
+ }
2738
2811
  });
2739
- opencodeClient = client;
2740
- console.log("OpenCode ready.\n");
2741
- // Subscribe to OpenCode events
2742
- subscribeToOpenCodeEvents(client);
2743
- const session = await createSessionFromManager();
2812
+ let session;
2813
+ if (cloudJobConfig?.session_code) {
2814
+ // Cloud mode: connect to an existing session assigned by the manager
2815
+ session = await connectToCloudSession(cloudJobConfig.session_code);
2816
+ console.log(`[cloud] Connected to session ${session.code} via ${session.primary}`);
2817
+ }
2818
+ else {
2819
+ // Public mode: create a new session and show QR code
2820
+ session = await createSessionFromManager();
2821
+ displayQR(normalizeGatewayUrl(session.primary), session.backup ? normalizeGatewayUrl(session.backup) : null, session.code);
2822
+ }
2744
2823
  currentPrimaryGateway = normalizeGatewayUrl(session.primary);
2745
2824
  currentBackupGateway = session.backup ? normalizeGatewayUrl(session.backup) : null;
2746
2825
  currentSessionPassword = session.password;
2747
2826
  activeGatewayUrl = currentPrimaryGateway;
2748
2827
  currentSessionCode = session.code;
2749
- displayQR(currentPrimaryGateway, currentBackupGateway, session.code);
2828
+ // Start VM heartbeat in cloud mode (session.password = resumeToken)
2829
+ if (cloudJobConfig?.session_code) {
2830
+ startVmHeartbeat(session.password);
2831
+ }
2750
2832
  await connectWebSocket();
2751
2833
  }
2752
2834
  catch (error) {