lunel-cli 0.1.33 → 0.1.35

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 +253 -40
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ const __require = createRequire(import.meta.url);
19
19
  const VERSION = __require("../package.json").version;
20
20
  const PTY_RELEASE_INFO_URL = process.env.LUNEL_PTY_INFO_URL ||
21
21
  "https://raw.githubusercontent.com/ssbharambe-m/pty-releases/refs/heads/main/info.json";
22
+ const VERBOSE_AI_LOGS = process.env.LUNEL_DEBUG_AI === "1";
22
23
  // Root directory - sandbox all file operations to this
23
24
  const ROOT_DIR = process.cwd();
24
25
  // Terminal sessions (managed by Rust PTY binary)
@@ -43,9 +44,72 @@ let activeControlWs = null;
43
44
  let activeDataWs = null;
44
45
  const activeTunnels = new Map();
45
46
  const PORT_SYNC_INTERVAL_MS = 30_000;
47
+ const CLI_LOCAL_TCP_CONNECT_TIMEOUT_MS = 2_500;
48
+ const PROXY_WS_CONNECT_TIMEOUT_MS = 12_000;
49
+ const TUNNEL_SETUP_BUDGET_MS = 18_000;
50
+ const PROXY_WS_CONNECT_RETRY_ATTEMPTS = 1;
51
+ const PROXY_WS_RETRY_JITTER_MIN_MS = 200;
52
+ const PROXY_WS_RETRY_JITTER_MAX_MS = 500;
53
+ const PROXY_TUNNEL_LINGER_MS = 1_200;
46
54
  let portSyncTimer = null;
47
55
  let portScanInFlight = false;
48
56
  let lastDiscoveredPorts = [];
57
+ function redactSensitive(input) {
58
+ const text = typeof input === "string" ? input : JSON.stringify(input);
59
+ return text
60
+ .replace(/([A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,})/g, "[redacted_jwt]")
61
+ .replace(/(password|token|authorization|resumeToken|x-manager-password)\s*[:=]\s*["']?[^"',\s}]+/gi, "$1=[redacted]")
62
+ .replace(/[A-Za-z0-9+/=_-]{40,}/g, "[redacted_secret]");
63
+ }
64
+ function parseProxyControlFrame(raw) {
65
+ const text = typeof raw === "string" ? raw : Buffer.isBuffer(raw) ? raw.toString("utf-8") : null;
66
+ if (!text)
67
+ return null;
68
+ try {
69
+ const parsed = JSON.parse(text);
70
+ if (parsed?.v !== 1 || parsed?.t !== "proxy_ctrl")
71
+ return null;
72
+ if (parsed.action !== "fin" && parsed.action !== "rst")
73
+ return null;
74
+ return {
75
+ v: 1,
76
+ t: "proxy_ctrl",
77
+ action: parsed.action,
78
+ reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
79
+ };
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
85
+ function sendProxyControl(tunnel, action, reason) {
86
+ if (tunnel.proxyWs.readyState !== WebSocket.OPEN)
87
+ return;
88
+ const frame = { v: 1, t: "proxy_ctrl", action, reason };
89
+ tunnel.proxyWs.send(JSON.stringify(frame));
90
+ }
91
+ function maybeFinalizeTunnel(tunnelId) {
92
+ const tunnel = activeTunnels.get(tunnelId);
93
+ if (!tunnel || tunnel.closing)
94
+ return;
95
+ if (!tunnel.localEnded || !tunnel.remoteEnded)
96
+ return;
97
+ if (tunnel.finalizeTimer)
98
+ return;
99
+ tunnel.finalizeTimer = setTimeout(() => {
100
+ const current = activeTunnels.get(tunnelId);
101
+ if (!current || current.closing)
102
+ return;
103
+ current.closing = true;
104
+ activeTunnels.delete(tunnelId);
105
+ if (!current.tcpSocket.destroyed) {
106
+ current.tcpSocket.destroy();
107
+ }
108
+ if (current.proxyWs.readyState === WebSocket.OPEN || current.proxyWs.readyState === WebSocket.CONNECTING) {
109
+ current.proxyWs.close();
110
+ }
111
+ }, PROXY_TUNNEL_LINGER_MS);
112
+ }
49
113
  // Popular development server ports to scan on connect
50
114
  const DEV_PORTS = [
51
115
  1234, // Parcel
@@ -1375,30 +1439,36 @@ function requireData(response, label) {
1375
1439
  const errMsg = response.error
1376
1440
  ? (typeof response.error === "string" ? response.error : JSON.stringify(response.error))
1377
1441
  : `${label} returned no data`;
1378
- console.error(`[ai] ${label} failed:`, errMsg, "raw response:", JSON.stringify(response).substring(0, 500));
1442
+ console.error(`[ai] ${label} failed:`, redactSensitive(errMsg), "raw response:", redactSensitive(JSON.stringify(response).substring(0, 500)));
1379
1443
  throw new Error(errMsg);
1380
1444
  }
1381
1445
  return response.data;
1382
1446
  }
1383
1447
  async function handleAiCreateSession(payload) {
1384
1448
  const title = payload.title || undefined;
1385
- console.log("[ai] createSession called, title:", title);
1449
+ if (VERBOSE_AI_LOGS)
1450
+ console.log("[ai] createSession called");
1386
1451
  try {
1387
1452
  const response = await opencodeClient.session.create({ body: { title } });
1388
- console.log("[ai] createSession response ok:", !!response.data, "error:", response.error ? JSON.stringify(response.error).substring(0, 200) : "none");
1453
+ if (VERBOSE_AI_LOGS) {
1454
+ console.log("[ai] createSession response ok:", !!response.data, "error:", response.error ? redactSensitive(JSON.stringify(response.error).substring(0, 200)) : "none");
1455
+ }
1389
1456
  return { session: requireData(response, "session.create") };
1390
1457
  }
1391
1458
  catch (err) {
1392
- console.error("[ai] createSession exception:", err.message, err.stack);
1459
+ console.error("[ai] createSession exception:", redactSensitive(err.message));
1393
1460
  throw err;
1394
1461
  }
1395
1462
  }
1396
1463
  async function handleAiListSessions() {
1397
- console.log("[ai] listSessions called");
1464
+ if (VERBOSE_AI_LOGS)
1465
+ console.log("[ai] listSessions called");
1398
1466
  try {
1399
1467
  const response = await opencodeClient.session.list();
1400
1468
  const data = requireData(response, "session.list");
1401
- console.log("[ai] listSessions returned", Array.isArray(data) ? data.length : typeof data, "sessions");
1469
+ if (VERBOSE_AI_LOGS) {
1470
+ console.log("[ai] listSessions returned", Array.isArray(data) ? data.length : typeof data, "sessions");
1471
+ }
1402
1472
  return { sessions: data };
1403
1473
  }
1404
1474
  catch (err) {
@@ -1420,7 +1490,8 @@ async function handleAiDeleteSession(payload) {
1420
1490
  }
1421
1491
  async function handleAiGetMessages(payload) {
1422
1492
  const id = payload.id;
1423
- console.log("[ai] getMessages called, sessionId:", id);
1493
+ if (VERBOSE_AI_LOGS)
1494
+ console.log("[ai] getMessages called");
1424
1495
  try {
1425
1496
  const response = await opencodeClient.session.messages({ path: { id } });
1426
1497
  const raw = requireData(response, "session.messages");
@@ -1432,7 +1503,8 @@ async function handleAiGetMessages(payload) {
1432
1503
  parts: m.parts || [],
1433
1504
  time: m.info.time,
1434
1505
  }));
1435
- console.log("[ai] getMessages returned", messages.length, "messages");
1506
+ if (VERBOSE_AI_LOGS)
1507
+ console.log("[ai] getMessages returned", messages.length, "messages");
1436
1508
  return { messages };
1437
1509
  }
1438
1510
  catch (err) {
@@ -1445,7 +1517,14 @@ async function handleAiPrompt(payload) {
1445
1517
  const text = payload.text;
1446
1518
  const model = payload.model;
1447
1519
  const agent = payload.agent;
1448
- console.log("[ai] prompt called, sessionId:", sessionId, "model:", JSON.stringify(model), "agent:", agent, "text:", text.substring(0, 100));
1520
+ if (VERBOSE_AI_LOGS) {
1521
+ console.log("[ai] prompt called", {
1522
+ hasSessionId: Boolean(sessionId),
1523
+ model: redactSensitive(JSON.stringify(model || {})),
1524
+ hasAgent: Boolean(agent),
1525
+ textLength: typeof text === "string" ? text.length : 0,
1526
+ });
1527
+ }
1449
1528
  // Fire and forget — results stream via SSE events forwarded on data channel
1450
1529
  opencodeClient.session.prompt({
1451
1530
  path: { id: sessionId },
@@ -1477,11 +1556,14 @@ async function handleAiAbort(payload) {
1477
1556
  return {};
1478
1557
  }
1479
1558
  async function handleAiAgents() {
1480
- console.log("[ai] getAgents called");
1559
+ if (VERBOSE_AI_LOGS)
1560
+ console.log("[ai] getAgents called");
1481
1561
  try {
1482
1562
  const response = await opencodeClient.app.agents();
1483
1563
  const data = requireData(response, "app.agents");
1484
- console.log("[ai] getAgents returned:", JSON.stringify(data).substring(0, 300));
1564
+ if (VERBOSE_AI_LOGS) {
1565
+ console.log("[ai] getAgents returned:", redactSensitive(JSON.stringify(data).substring(0, 300)));
1566
+ }
1485
1567
  return { agents: data };
1486
1568
  }
1487
1569
  catch (err) {
@@ -1490,11 +1572,14 @@ async function handleAiAgents() {
1490
1572
  }
1491
1573
  }
1492
1574
  async function handleAiProviders() {
1493
- console.log("[ai] getProviders called");
1575
+ if (VERBOSE_AI_LOGS)
1576
+ console.log("[ai] getProviders called");
1494
1577
  try {
1495
1578
  const response = await opencodeClient.config.providers();
1496
1579
  const data = requireData(response, "config.providers");
1497
- console.log("[ai] getProviders returned", data.providers?.length, "providers, defaults:", JSON.stringify(data.default));
1580
+ if (VERBOSE_AI_LOGS) {
1581
+ console.log("[ai] getProviders returned", data.providers?.length, "providers, defaults:", redactSensitive(JSON.stringify(data.default)));
1582
+ }
1498
1583
  return { providers: data.providers, default: data.default };
1499
1584
  }
1500
1585
  catch (err) {
@@ -1572,7 +1657,7 @@ async function subscribeToOpenCodeEvents(client) {
1572
1657
  ? parsed.payload
1573
1658
  : parsed;
1574
1659
  if (!base || typeof base.type !== "string") {
1575
- console.warn("[sse] Dropped malformed event:", JSON.stringify(parsed).substring(0, 200));
1660
+ console.warn("[sse] Dropped malformed event:", redactSensitive(JSON.stringify(parsed).substring(0, 200)));
1576
1661
  continue;
1577
1662
  }
1578
1663
  console.log("[sse]", base.type);
@@ -1669,19 +1754,25 @@ function startPortSync(ws) {
1669
1754
  async function handleProxyConnect(payload) {
1670
1755
  const tunnelId = payload.tunnelId;
1671
1756
  const port = payload.port;
1757
+ const setupStartedAt = Date.now();
1758
+ const getRemainingSetupMs = () => TUNNEL_SETUP_BUDGET_MS - (Date.now() - setupStartedAt);
1672
1759
  if (!tunnelId)
1673
1760
  throw Object.assign(new Error("tunnelId is required"), { code: "EINVAL" });
1674
1761
  if (!port)
1675
1762
  throw Object.assign(new Error("port is required"), { code: "EINVAL" });
1676
1763
  if (!currentSessionCode && !currentSessionPassword)
1677
1764
  throw Object.assign(new Error("no active session"), { code: "ENOENT" });
1765
+ if (getRemainingSetupMs() <= 0) {
1766
+ throw Object.assign(new Error("Tunnel setup timeout before start"), { code: "ETIMEOUT" });
1767
+ }
1678
1768
  // 1. Open TCP connection to the local service
1679
1769
  const tcpSocket = createConnection({ port, host: "127.0.0.1" });
1770
+ const tcpConnectTimeoutMs = Math.min(CLI_LOCAL_TCP_CONNECT_TIMEOUT_MS, Math.max(250, getRemainingSetupMs()));
1680
1771
  await new Promise((resolve, reject) => {
1681
1772
  const timeout = setTimeout(() => {
1682
1773
  tcpSocket.destroy();
1683
1774
  reject(Object.assign(new Error(`TCP connect timeout to localhost:${port}`), { code: "ETIMEOUT" }));
1684
- }, 5000);
1775
+ }, tcpConnectTimeoutMs);
1685
1776
  tcpSocket.on("connect", () => {
1686
1777
  clearTimeout(timeout);
1687
1778
  resolve();
@@ -1697,25 +1788,74 @@ async function handleProxyConnect(payload) {
1697
1788
  ? `password=${encodeURIComponent(currentSessionPassword)}`
1698
1789
  : `code=${encodeURIComponent(currentSessionCode)}`;
1699
1790
  const proxyWsUrl = `${wsBase}/v1/ws/proxy?${authQuery}&tunnelId=${encodeURIComponent(tunnelId)}&role=cli`;
1700
- const proxyWs = new WebSocket(proxyWsUrl);
1701
- await new Promise((resolve, reject) => {
1702
- const timeout = setTimeout(() => {
1703
- proxyWs.close();
1791
+ let proxyWs = null;
1792
+ let lastProxyError = null;
1793
+ for (let attempt = 0; attempt <= PROXY_WS_CONNECT_RETRY_ATTEMPTS; attempt++) {
1794
+ const remainingMs = getRemainingSetupMs();
1795
+ if (remainingMs <= 0) {
1704
1796
  tcpSocket.destroy();
1705
- reject(Object.assign(new Error("Proxy WS connect timeout"), { code: "ETIMEOUT" }));
1706
- }, 5000);
1707
- proxyWs.on("open", () => {
1708
- clearTimeout(timeout);
1709
- resolve();
1710
- });
1711
- proxyWs.on("error", (err) => {
1712
- clearTimeout(timeout);
1713
- tcpSocket.destroy();
1714
- reject(Object.assign(new Error(`Proxy WS failed: ${err.message}`), { code: "ECONNREFUSED" }));
1715
- });
1716
- });
1797
+ throw Object.assign(new Error("Tunnel setup timeout while connecting proxy WS"), { code: "ETIMEOUT" });
1798
+ }
1799
+ const wsConnectTimeoutMs = Math.min(PROXY_WS_CONNECT_TIMEOUT_MS, Math.max(250, remainingMs));
1800
+ const candidateWs = new WebSocket(proxyWsUrl);
1801
+ try {
1802
+ await new Promise((resolve, reject) => {
1803
+ const timeout = setTimeout(() => {
1804
+ candidateWs.close();
1805
+ reject(Object.assign(new Error("Proxy WS connect timeout"), { code: "ETIMEOUT" }));
1806
+ }, wsConnectTimeoutMs);
1807
+ candidateWs.on("open", () => {
1808
+ clearTimeout(timeout);
1809
+ resolve();
1810
+ });
1811
+ candidateWs.on("error", (err) => {
1812
+ clearTimeout(timeout);
1813
+ reject(Object.assign(new Error(`Proxy WS failed: ${err.message}`), { code: "ECONNREFUSED" }));
1814
+ });
1815
+ candidateWs.on("close", () => {
1816
+ clearTimeout(timeout);
1817
+ reject(Object.assign(new Error("Proxy WS closed during connect"), { code: "ECONNRESET" }));
1818
+ });
1819
+ });
1820
+ proxyWs = candidateWs;
1821
+ break;
1822
+ }
1823
+ catch (error) {
1824
+ lastProxyError = error;
1825
+ try {
1826
+ candidateWs.close();
1827
+ }
1828
+ catch {
1829
+ // ignore
1830
+ }
1831
+ if (attempt >= PROXY_WS_CONNECT_RETRY_ATTEMPTS) {
1832
+ break;
1833
+ }
1834
+ const jitterSpan = PROXY_WS_RETRY_JITTER_MAX_MS - PROXY_WS_RETRY_JITTER_MIN_MS;
1835
+ const jitterMs = PROXY_WS_RETRY_JITTER_MIN_MS + Math.floor(Math.random() * (jitterSpan + 1));
1836
+ if (getRemainingSetupMs() <= jitterMs) {
1837
+ break;
1838
+ }
1839
+ await new Promise((resolve) => setTimeout(resolve, jitterMs));
1840
+ }
1841
+ }
1842
+ if (!proxyWs) {
1843
+ tcpSocket.destroy();
1844
+ const err = lastProxyError || Object.assign(new Error("Proxy WS connect failed"), { code: "ECONNREFUSED" });
1845
+ throw err;
1846
+ }
1717
1847
  // 3. Store the tunnel
1718
- activeTunnels.set(tunnelId, { tunnelId, port, tcpSocket, proxyWs });
1848
+ activeTunnels.set(tunnelId, {
1849
+ tunnelId,
1850
+ port,
1851
+ tcpSocket,
1852
+ proxyWs,
1853
+ localEnded: false,
1854
+ remoteEnded: false,
1855
+ finSent: false,
1856
+ finalizeTimer: null,
1857
+ closing: false,
1858
+ });
1719
1859
  // 4. Pipe: TCP data -> proxy WS (as binary)
1720
1860
  tcpSocket.on("data", (chunk) => {
1721
1861
  if (proxyWs.readyState === WebSocket.OPEN) {
@@ -1724,18 +1864,70 @@ async function handleProxyConnect(payload) {
1724
1864
  });
1725
1865
  // 5. Pipe: proxy WS -> TCP socket (as binary)
1726
1866
  proxyWs.on("message", (data) => {
1867
+ const control = parseProxyControlFrame(data);
1868
+ if (control) {
1869
+ const tunnel = activeTunnels.get(tunnelId);
1870
+ if (!tunnel || tunnel.closing)
1871
+ return;
1872
+ if (control.action === "fin") {
1873
+ tunnel.remoteEnded = true;
1874
+ if (!tcpSocket.destroyed) {
1875
+ tcpSocket.end();
1876
+ }
1877
+ maybeFinalizeTunnel(tunnelId);
1878
+ }
1879
+ else {
1880
+ tunnel.closing = true;
1881
+ activeTunnels.delete(tunnelId);
1882
+ if (!tcpSocket.destroyed) {
1883
+ tcpSocket.destroy();
1884
+ }
1885
+ if (proxyWs.readyState === WebSocket.OPEN || proxyWs.readyState === WebSocket.CONNECTING) {
1886
+ proxyWs.close();
1887
+ }
1888
+ }
1889
+ return;
1890
+ }
1727
1891
  if (!tcpSocket.destroyed) {
1728
- tcpSocket.write(data);
1892
+ const chunk = Buffer.isBuffer(data)
1893
+ ? data
1894
+ : typeof data === "string"
1895
+ ? Buffer.from(data)
1896
+ : Array.isArray(data)
1897
+ ? Buffer.concat(data)
1898
+ : Buffer.from(data);
1899
+ tcpSocket.write(chunk);
1729
1900
  }
1730
1901
  });
1731
- // 6. Close cascade: TCP closes -> close WS
1732
- tcpSocket.on("close", () => {
1733
- activeTunnels.delete(tunnelId);
1734
- if (proxyWs.readyState === WebSocket.OPEN || proxyWs.readyState === WebSocket.CONNECTING) {
1735
- proxyWs.close();
1902
+ const markLocalEnded = () => {
1903
+ const tunnel = activeTunnels.get(tunnelId);
1904
+ if (!tunnel || tunnel.closing)
1905
+ return;
1906
+ tunnel.localEnded = true;
1907
+ if (!tunnel.finSent) {
1908
+ tunnel.finSent = true;
1909
+ sendProxyControl(tunnel, "fin");
1736
1910
  }
1911
+ maybeFinalizeTunnel(tunnelId);
1912
+ };
1913
+ // 6. Half-close handling
1914
+ tcpSocket.on("end", () => {
1915
+ markLocalEnded();
1916
+ });
1917
+ tcpSocket.on("close", () => {
1918
+ markLocalEnded();
1737
1919
  });
1738
1920
  tcpSocket.on("error", () => {
1921
+ const tunnel = activeTunnels.get(tunnelId);
1922
+ if (tunnel && !tunnel.finSent) {
1923
+ sendProxyControl(tunnel, "rst", "tcp_error");
1924
+ }
1925
+ if (tunnel) {
1926
+ tunnel.closing = true;
1927
+ if (tunnel.finalizeTimer) {
1928
+ clearTimeout(tunnel.finalizeTimer);
1929
+ }
1930
+ }
1739
1931
  activeTunnels.delete(tunnelId);
1740
1932
  if (proxyWs.readyState === WebSocket.OPEN || proxyWs.readyState === WebSocket.CONNECTING) {
1741
1933
  proxyWs.close();
@@ -1743,12 +1935,26 @@ async function handleProxyConnect(payload) {
1743
1935
  });
1744
1936
  // 7. Close cascade: WS closes -> close TCP
1745
1937
  proxyWs.on("close", () => {
1938
+ const tunnel = activeTunnels.get(tunnelId);
1939
+ if (tunnel) {
1940
+ tunnel.closing = true;
1941
+ if (tunnel.finalizeTimer) {
1942
+ clearTimeout(tunnel.finalizeTimer);
1943
+ }
1944
+ }
1746
1945
  activeTunnels.delete(tunnelId);
1747
1946
  if (!tcpSocket.destroyed) {
1748
1947
  tcpSocket.destroy();
1749
1948
  }
1750
1949
  });
1751
1950
  proxyWs.on("error", () => {
1951
+ const tunnel = activeTunnels.get(tunnelId);
1952
+ if (tunnel) {
1953
+ tunnel.closing = true;
1954
+ if (tunnel.finalizeTimer) {
1955
+ clearTimeout(tunnel.finalizeTimer);
1956
+ }
1957
+ }
1752
1958
  activeTunnels.delete(tunnelId);
1753
1959
  if (!tcpSocket.destroyed) {
1754
1960
  tcpSocket.destroy();
@@ -1758,6 +1964,9 @@ async function handleProxyConnect(payload) {
1758
1964
  }
1759
1965
  function cleanupAllTunnels() {
1760
1966
  for (const [, tunnel] of activeTunnels) {
1967
+ if (tunnel.finalizeTimer) {
1968
+ clearTimeout(tunnel.finalizeTimer);
1969
+ }
1761
1970
  tunnel.tcpSocket.destroy();
1762
1971
  if (tunnel.proxyWs.readyState === WebSocket.OPEN) {
1763
1972
  tunnel.proxyWs.close();
@@ -1784,7 +1993,7 @@ async function processMessage(message) {
1784
1993
  }
1785
1994
  // Validate required fields
1786
1995
  if (!ns || !action) {
1787
- console.warn("[router] Ignoring message with missing ns/action:", JSON.stringify(message).substring(0, 300));
1996
+ console.warn("[router] Ignoring message with missing ns/action:", redactSensitive(JSON.stringify(message).substring(0, 300)));
1788
1997
  return {
1789
1998
  v: 1,
1790
1999
  id,
@@ -2198,7 +2407,11 @@ async function connectWebSocket() {
2198
2407
  return;
2199
2408
  }
2200
2409
  if (message.type === "close_connection") {
2201
- console.log(`[session] closed by gateway: ${message.reason || "expired"}`);
2410
+ const reason = message.reason || "expired";
2411
+ console.log(`[session] closed by gateway: ${reason}`);
2412
+ if (reason === "session ended from app") {
2413
+ console.log("[session] Run `npx lunel-cli` again and scan the new QR code to reconnect.");
2414
+ }
2202
2415
  gracefulShutdown();
2203
2416
  return;
2204
2417
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lunel-cli",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "author": [
5
5
  {
6
6
  "name": "Soham Bharambe",