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/ai/codex.d.ts +46 -0
- package/dist/ai/codex.js +271 -0
- package/dist/ai/index.d.ts +47 -0
- package/dist/ai/index.js +92 -0
- package/dist/ai/interface.d.ts +72 -0
- package/dist/ai/interface.js +3 -0
- package/dist/ai/opencode.d.ts +44 -0
- package/dist/ai/opencode.js +315 -0
- package/dist/index.js +408 -326
- package/package.json +2 -1
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 {
|
|
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
|
|
35
|
-
let
|
|
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
|
-
|
|
224
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
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
|
-
|
|
1310
|
-
const
|
|
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
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
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 ${
|
|
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
|
|
1814
|
+
// AI Handlers — delegated to the active AIProvider
|
|
1556
1815
|
// ============================================================================
|
|
1557
|
-
|
|
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
|
|
2366
|
+
result = await aiManager.prompt(backend, payload.sessionId, payload.text, payload.model, payload.agent);
|
|
2351
2367
|
break;
|
|
2352
2368
|
case "createSession":
|
|
2353
|
-
result = await
|
|
2369
|
+
result = await aiManager.createSession(backend, payload.title);
|
|
2354
2370
|
break;
|
|
2355
2371
|
case "listSessions":
|
|
2356
|
-
result = await
|
|
2372
|
+
result = await aiManager.listAllSessions();
|
|
2357
2373
|
break;
|
|
2358
2374
|
case "getSession":
|
|
2359
|
-
result = await
|
|
2375
|
+
result = await aiManager.getSession(backend, payload.id);
|
|
2360
2376
|
break;
|
|
2361
2377
|
case "deleteSession":
|
|
2362
|
-
result = await
|
|
2378
|
+
result = await aiManager.deleteSession(backend, payload.id);
|
|
2363
2379
|
break;
|
|
2364
2380
|
case "getMessages":
|
|
2365
|
-
result = await
|
|
2381
|
+
result = await aiManager.getMessages(backend, payload.id);
|
|
2366
2382
|
break;
|
|
2367
2383
|
case "abort":
|
|
2368
|
-
result = await
|
|
2384
|
+
result = await aiManager.abort(backend, payload.sessionId);
|
|
2369
2385
|
break;
|
|
2370
2386
|
case "agents":
|
|
2371
|
-
result = await
|
|
2387
|
+
result = await aiManager.agents(backend);
|
|
2372
2388
|
break;
|
|
2373
2389
|
case "providers":
|
|
2374
|
-
result = await
|
|
2390
|
+
result = await aiManager.providers(backend);
|
|
2375
2391
|
break;
|
|
2376
2392
|
case "setAuth":
|
|
2377
|
-
result = await
|
|
2393
|
+
result = await aiManager.setAuth(backend, payload.providerId, payload.key);
|
|
2378
2394
|
break;
|
|
2379
2395
|
case "command":
|
|
2380
|
-
result = await
|
|
2396
|
+
result = await aiManager.command(backend, payload.sessionId, payload.command, payload.arguments || "");
|
|
2381
2397
|
break;
|
|
2382
2398
|
case "revert":
|
|
2383
|
-
result = await
|
|
2399
|
+
result = await aiManager.revert(backend, payload.sessionId, payload.messageId);
|
|
2384
2400
|
break;
|
|
2385
2401
|
case "unrevert":
|
|
2386
|
-
result = await
|
|
2402
|
+
result = await aiManager.unrevert(backend, payload.sessionId);
|
|
2387
2403
|
break;
|
|
2388
2404
|
case "share":
|
|
2389
|
-
result = await
|
|
2405
|
+
result = await aiManager.share(backend, payload.sessionId);
|
|
2390
2406
|
break;
|
|
2391
|
-
case "permission":
|
|
2392
|
-
|
|
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
|
-
|
|
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
|
|
2644
|
-
if (
|
|
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
|
-
//
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
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
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
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
|
-
|
|
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) {
|