@zhanguym/chatroom 1.1.0 → 1.2.0-beta.1
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/app/application.d.ts +2 -0
- package/dist/app/application.js +7 -1
- package/dist/app/lifecycle.js +6 -0
- package/dist/auth/auth-service.js +5 -1
- package/dist/core/runtime/child-environment.js +3 -2
- package/dist/infrastructure/database/custom-mcp-repository.d.ts +9 -0
- package/dist/infrastructure/database/custom-mcp-repository.js +37 -0
- package/dist/infrastructure/database/mcp-tool-settings-repository.d.ts +1 -0
- package/dist/infrastructure/database/mcp-tool-settings-repository.js +6 -0
- package/dist/infrastructure/database/schema.d.ts +1 -1
- package/dist/infrastructure/database/schema.js +9 -0
- package/dist/infrastructure/http/http-server.js +7 -1
- package/dist/mcp/custom/manager.d.ts +54 -0
- package/dist/mcp/custom/manager.js +494 -0
- package/dist/mcp/custom/oauth.d.ts +38 -0
- package/dist/mcp/custom/oauth.js +116 -0
- package/dist/mcp/custom/types.d.ts +42 -0
- package/dist/mcp/custom/types.js +1 -0
- package/dist/mcp/server/create-mcp-server.d.ts +2 -1
- package/dist/mcp/server/create-mcp-server.js +4 -3
- package/dist/mcp/server/tool-control.d.ts +5 -0
- package/dist/mcp/server/tool-control.js +17 -0
- package/dist/native/linux/chatroom-computer-helper +0 -0
- package/dist/native/macos/ChatRoomComputerHelper.app/Contents/MacOS/chatroom-computer-helper +0 -0
- package/dist/native/windows/chatroom-computer-helper.exe +0 -0
- package/dist/plugins/cloud/tunnel-client.d.ts +2 -84
- package/dist/plugins/cloud/tunnel-client.js +2 -426
- package/dist/plugins/cloud/v2/stream.d.ts +48 -0
- package/dist/plugins/cloud/v2/stream.js +156 -0
- package/dist/plugins/cloud/v2/tunnel-client.d.ts +45 -0
- package/dist/plugins/cloud/v2/tunnel-client.js +426 -0
- package/dist/plugins/computer/computer-schemas.d.ts +8 -8
- package/dist/plugins/process/backend.d.ts +3 -0
- package/dist/plugins/process/infrastructure/pty-process-backend.d.ts +2 -0
- package/dist/plugins/process/infrastructure/pty-process-backend.js +5 -2
- package/dist/plugins/process/infrastructure/terminal-shell.d.ts +4 -0
- package/dist/plugins/process/infrastructure/terminal-shell.js +10 -0
- package/dist/plugins/process/process-supervisor.d.ts +6 -0
- package/dist/plugins/process/process-supervisor.js +26 -0
- package/dist/plugins/process/types.d.ts +2 -0
- package/dist/plugins/types.d.ts +2 -0
- package/dist/plugins/web/api-types.d.ts +2 -0
- package/dist/plugins/web/http/api-router.d.ts +2 -1
- package/dist/plugins/web/http/api-router.js +8 -1
- package/dist/plugins/web/http/custom-mcp-api-router.d.ts +6 -0
- package/dist/plugins/web/http/custom-mcp-api-router.js +114 -0
- package/dist/plugins/web/http/logs-api-router.js +2 -2
- package/dist/plugins/web/http/process-api-router.js +48 -0
- package/dist/plugins/web/http/rtc-api-router.d.ts +4 -0
- package/dist/plugins/web/http/rtc-api-router.js +316 -0
- package/dist/plugins/web/http/runtime-api-router.js +2 -2
- package/dist/plugins/web/http/terminal-api-router.d.ts +4 -0
- package/dist/plugins/web/http/terminal-api-router.js +132 -0
- package/dist/plugins/web/plugin.js +1 -1
- package/dist/plugins/web/runtime.d.ts +3 -1
- package/dist/plugins/web/runtime.js +3 -1
- package/dist/web/assets/AgentView-BEA5mQVv.js +4 -0
- package/dist/web/assets/{AgentView-BGLIBoxF.css → AgentView-CMxIn3KB.css} +1 -1
- package/dist/web/assets/CloudView-BGcVSE22.js +1 -0
- package/dist/web/assets/CloudView-Bgp5G965.css +1 -0
- package/dist/web/assets/CodeViewer.vue_vue_type_script_setup_true_lang-DfPmlGC0.js +3 -0
- package/dist/web/assets/ComputerView-Bz2Wsz2X.css +1 -0
- package/dist/web/assets/ComputerView-D6_NSXKb.js +1 -0
- package/dist/web/assets/{MarkdownContent-CkjR5fs7.js → MarkdownContent-DCkAALOF.js} +2 -2
- package/dist/web/assets/McpToolsView--8UloObb.js +2 -0
- package/dist/web/assets/McpToolsView-DdYOoY9_.css +1 -0
- package/dist/web/assets/OperationsView-CqgAPnSL.js +4 -0
- package/dist/web/assets/ProcessesView-BmNdR3C0.css +1 -0
- package/dist/web/assets/ProcessesView-DC0tjiYU.js +5 -0
- package/dist/web/assets/StateChip.vue_vue_type_script_setup_true_lang-D_LKEtqT.js +1 -0
- package/dist/web/assets/SystemLogsView-COvwamo5.css +1 -0
- package/dist/web/assets/SystemLogsView-DkTeUr2k.js +1 -0
- package/dist/web/assets/TerminalView-Coze7IMj.css +1 -0
- package/dist/web/assets/TerminalView-DEP1U0aW.js +16 -0
- package/dist/web/assets/VSelect-B5-t612C.js +1 -0
- package/dist/web/assets/VSheet-CNayLacf.js +1 -0
- package/dist/web/assets/VSpacer-BYe3Z99W.js +1 -0
- package/dist/web/assets/VSwitch-cVRCnipJ.js +1 -0
- package/dist/web/assets/VTabs-HKX286IT.js +1 -0
- package/dist/web/assets/VTextarea-ypCBuKI-.js +1 -0
- package/dist/web/assets/WorkspaceDownloadPreview-DJKSNaZ_.css +1 -0
- package/dist/web/assets/WorkspaceDownloadPreview-qwbTkU59.js +1 -0
- package/dist/web/assets/WorkspaceMediaPreview-D28Yf2yb.js +4 -0
- package/dist/web/assets/{WorkspacePdfPreview-CO-ZtDHG.js → WorkspacePdfPreview-BfZSPcR-.js} +2 -2
- package/dist/web/assets/WorkspacesView-CsCGtk5H.js +2 -0
- package/dist/web/assets/index-BCUxnASh.css +1 -0
- package/dist/web/assets/index-CFhV1jOj.js +19 -0
- package/dist/web/assets/{useMasterDetailFocus-D1mQ8Qjk.js → useMasterDetailFocus-CMJXBPPp.js} +1 -1
- package/dist/web/assets/useOperationLabels-DSbMa9Eb.js +1 -0
- package/dist/web/index.html +2 -2
- package/dist/web/transport-sw.js +417 -0
- package/package.json +10 -4
- package/scripts/ensure-pty-helper.mjs +28 -0
- package/dist/web/assets/AgentView-Dd6zCbr-.js +0 -4
- package/dist/web/assets/CloudView-DVVvwuWW.css +0 -1
- package/dist/web/assets/CloudView-DquY1HDZ.js +0 -1
- package/dist/web/assets/CodeViewer.vue_vue_type_script_setup_true_lang-ekOImmBP.js +0 -3
- package/dist/web/assets/ComputerView-DWzfSYJ7.css +0 -1
- package/dist/web/assets/ComputerView-RD2gMxJZ.js +0 -1
- package/dist/web/assets/McpToolsView-Dg2-iaDP.js +0 -1
- package/dist/web/assets/McpToolsView-DjxPbTHj.css +0 -1
- package/dist/web/assets/OperationsView-C8yCX7WZ.js +0 -4
- package/dist/web/assets/ProcessesView-C834LC-f.js +0 -4
- package/dist/web/assets/ProcessesView-Ce6iVwjA.css +0 -1
- package/dist/web/assets/StateChip.vue_vue_type_script_setup_true_lang-By2gilBw.js +0 -1
- package/dist/web/assets/SystemLogsView-B6KcZv1J.js +0 -1
- package/dist/web/assets/SystemLogsView-CofP4Jvi.css +0 -1
- package/dist/web/assets/VSelect-BtAJX-XJ.js +0 -1
- package/dist/web/assets/VSpacer-M8bpkhTH.js +0 -1
- package/dist/web/assets/VSwitch-C0VTnwLJ.js +0 -1
- package/dist/web/assets/VTabs-g61mykvY.js +0 -1
- package/dist/web/assets/VTextarea-CRYQYRqg.js +0 -1
- package/dist/web/assets/WorkspaceDownloadPreview-BrESF-82.js +0 -1
- package/dist/web/assets/WorkspaceDownloadPreview-DnxhH5Q7.css +0 -1
- package/dist/web/assets/WorkspaceMediaPreview-HYajqgZ3.js +0 -4
- package/dist/web/assets/WorkspacesView-BJCnzBLj.js +0 -2
- package/dist/web/assets/_plugin-vue_export-helper-DlAUqK2U.js +0 -1
- package/dist/web/assets/index-CZ-4tJlF.css +0 -1
- package/dist/web/assets/index-u-aADQP-.js +0 -19
- package/dist/web/assets/useOperationLabels-BjPIVnJW.js +0 -1
- package/dist/web/assets/utils-rtPwWNel.js +0 -1
- /package/dist/web/assets/{CodeViewer-CH1DEJge.css → VSheet-CH1DEJge.css} +0 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { Router } from "express";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { CreditSender, INITIAL_CREDIT, creditFrame, decodeFrame, metaFrame, parseMeta, readCredit, } from "#plugins/cloud/v2/stream";
|
|
6
|
+
const offerSchema = z
|
|
7
|
+
.object({
|
|
8
|
+
type: z.literal("offer"),
|
|
9
|
+
sdp: z.string().min(1).max(64_000),
|
|
10
|
+
})
|
|
11
|
+
.strict();
|
|
12
|
+
const openSchema = z
|
|
13
|
+
.object({
|
|
14
|
+
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
|
15
|
+
path: z.string().max(8192),
|
|
16
|
+
headers: z.record(z.string(), z.string()).default({}),
|
|
17
|
+
})
|
|
18
|
+
.strict();
|
|
19
|
+
const STUN = ["stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302"];
|
|
20
|
+
const MAX_PEERS = 8;
|
|
21
|
+
const MAX_STREAMS = 32;
|
|
22
|
+
const MAX_UPLOAD = 8 * 1024 * 1024;
|
|
23
|
+
const MAX_IDLE_MS = 30 * 60 * 1000;
|
|
24
|
+
const HEADER_NAMES = new Set([
|
|
25
|
+
"accept",
|
|
26
|
+
"content-type",
|
|
27
|
+
"range",
|
|
28
|
+
"if-none-match",
|
|
29
|
+
"if-modified-since",
|
|
30
|
+
"x-chatroom-mime-type",
|
|
31
|
+
]);
|
|
32
|
+
const RESPONSE_HEADERS = new Set([
|
|
33
|
+
"content-type",
|
|
34
|
+
"content-disposition",
|
|
35
|
+
"content-length",
|
|
36
|
+
"content-range",
|
|
37
|
+
"accept-ranges",
|
|
38
|
+
"cache-control",
|
|
39
|
+
"etag",
|
|
40
|
+
"last-modified",
|
|
41
|
+
]);
|
|
42
|
+
/** V2 DataChannel HTTP transport. Cloud and self-hosting use the same HTTPS signaling API. */
|
|
43
|
+
export function createRtcApiRouter(config) {
|
|
44
|
+
const router = Router();
|
|
45
|
+
const peers = new Map();
|
|
46
|
+
router.post("/rtc/offer", async (req, res) => {
|
|
47
|
+
const parsed = offerSchema.safeParse(req.body);
|
|
48
|
+
if (!parsed.success ||
|
|
49
|
+
!parsed.data.sdp.includes("m=application ") ||
|
|
50
|
+
!parsed.data.sdp.includes("a=fingerprint:") ||
|
|
51
|
+
!parsed.data.sdp.includes("a=ice-ufrag:")) {
|
|
52
|
+
res.status(400).json({ error: "invalid_offer" });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (peers.size >= MAX_PEERS) {
|
|
56
|
+
res.status(429).json({ error: "rtc_capacity" });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
let library;
|
|
60
|
+
try {
|
|
61
|
+
library = await import("node-datachannel");
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
res.status(503).json({ error: "rtc_unavailable" });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const id = randomUUID();
|
|
68
|
+
const pc = new library.PeerConnection(id, { iceServers: STUN });
|
|
69
|
+
peers.set(id, pc);
|
|
70
|
+
const streams = new Map();
|
|
71
|
+
let dc = null;
|
|
72
|
+
let closed = false;
|
|
73
|
+
const cookie = req.headers.cookie ?? "";
|
|
74
|
+
const host = req.headers.host ?? "localhost";
|
|
75
|
+
const origin = req.headers.origin ?? "";
|
|
76
|
+
const cleanup = () => {
|
|
77
|
+
if (closed)
|
|
78
|
+
return;
|
|
79
|
+
closed = true;
|
|
80
|
+
clearTimeout(expiry);
|
|
81
|
+
for (const stream of streams.values()) {
|
|
82
|
+
stream.sender?.cancel();
|
|
83
|
+
stream.request.destroy();
|
|
84
|
+
}
|
|
85
|
+
streams.clear();
|
|
86
|
+
peers.delete(id);
|
|
87
|
+
try {
|
|
88
|
+
dc?.close();
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
/* connection already closed */
|
|
92
|
+
}
|
|
93
|
+
pc.close();
|
|
94
|
+
};
|
|
95
|
+
let expiry;
|
|
96
|
+
const activity = () => {
|
|
97
|
+
clearTimeout(expiry);
|
|
98
|
+
expiry = setTimeout(cleanup, MAX_IDLE_MS);
|
|
99
|
+
expiry.unref();
|
|
100
|
+
};
|
|
101
|
+
activity();
|
|
102
|
+
pc.onStateChange((state) => {
|
|
103
|
+
if (state === "closed" || state === "failed")
|
|
104
|
+
cleanup();
|
|
105
|
+
});
|
|
106
|
+
pc.onDataChannel((channel) => {
|
|
107
|
+
if (closed || dc || channel.getLabel() !== "chatroom-v2") {
|
|
108
|
+
channel.close();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
dc = channel;
|
|
112
|
+
channel.setBufferedAmountLowThreshold(512 * 1024);
|
|
113
|
+
channel.onBufferedAmountLow(() => {
|
|
114
|
+
for (const stream of streams.values())
|
|
115
|
+
stream.sender?.writable();
|
|
116
|
+
});
|
|
117
|
+
const send = (frame) => {
|
|
118
|
+
try {
|
|
119
|
+
if (!channel.isOpen() ||
|
|
120
|
+
channel.bufferedAmount() > 4 * 1024 * 1024 ||
|
|
121
|
+
!channel.sendMessageBinary(Buffer.from(frame))) {
|
|
122
|
+
cleanup();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
activity();
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
cleanup();
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const abort = (streamId) => {
|
|
132
|
+
const stream = streams.get(streamId);
|
|
133
|
+
if (!stream)
|
|
134
|
+
return;
|
|
135
|
+
streams.delete(streamId);
|
|
136
|
+
stream.sender?.cancel();
|
|
137
|
+
stream.request.destroy();
|
|
138
|
+
};
|
|
139
|
+
const reset = (streamId, reason) => {
|
|
140
|
+
if (!streams.has(streamId))
|
|
141
|
+
return;
|
|
142
|
+
abort(streamId);
|
|
143
|
+
send(metaFrame(5 /* Op.RESET */, streamId, { reason }));
|
|
144
|
+
};
|
|
145
|
+
channel.onMessage((raw) => {
|
|
146
|
+
if (closed || typeof raw === "string") {
|
|
147
|
+
cleanup();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
activity();
|
|
152
|
+
const frame = decodeFrame(new Uint8Array(raw));
|
|
153
|
+
if (!frame.id)
|
|
154
|
+
throw Error("Reserved RTC control stream");
|
|
155
|
+
if (frame.op === 1 /* Op.OPEN */) {
|
|
156
|
+
if (streams.has(frame.id) || streams.size >= MAX_STREAMS) {
|
|
157
|
+
reset(frame.id, "capacity");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const open = openSchema.parse(parseMeta(frame.payload));
|
|
161
|
+
if (!(open.path.startsWith("/api/") ||
|
|
162
|
+
open.path.startsWith("/assets/")) ||
|
|
163
|
+
open.path.startsWith("/api/rtc/") ||
|
|
164
|
+
(open.path.startsWith("/assets/") && open.method !== "GET") ||
|
|
165
|
+
open.path.includes("#")) {
|
|
166
|
+
reset(frame.id, "invalid_path");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const headers = { host, cookie };
|
|
170
|
+
if (origin)
|
|
171
|
+
headers.origin = origin;
|
|
172
|
+
for (const [name, value] of Object.entries(open.headers))
|
|
173
|
+
if (HEADER_NAMES.has(name.toLowerCase()))
|
|
174
|
+
headers[name.toLowerCase()] = value;
|
|
175
|
+
const request = http.request({
|
|
176
|
+
host: ["0.0.0.0", "::"].includes(config.server.host)
|
|
177
|
+
? "127.0.0.1"
|
|
178
|
+
: config.server.host,
|
|
179
|
+
port: config.server.port,
|
|
180
|
+
method: open.method,
|
|
181
|
+
path: open.path,
|
|
182
|
+
headers,
|
|
183
|
+
}, (response) => {
|
|
184
|
+
const stream = streams.get(frame.id);
|
|
185
|
+
if (!stream) {
|
|
186
|
+
response.destroy();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const responseHeaders = Object.fromEntries(Object.entries(response.headers).filter(([name, value]) => RESPONSE_HEADERS.has(name) && typeof value === "string"));
|
|
190
|
+
send(metaFrame(2 /* Op.META */, frame.id, {
|
|
191
|
+
status: response.statusCode ?? 502,
|
|
192
|
+
headers: responseHeaders,
|
|
193
|
+
}));
|
|
194
|
+
const writer = new CreditSender(frame.id, send, () => response.pause(), () => response.resume(), () => {
|
|
195
|
+
streams.delete(frame.id);
|
|
196
|
+
}, () => channel.bufferedAmount() < 1024 * 1024);
|
|
197
|
+
stream.sender = writer;
|
|
198
|
+
response.on("data", (chunk) => {
|
|
199
|
+
if (!streams.has(frame.id))
|
|
200
|
+
return;
|
|
201
|
+
try {
|
|
202
|
+
writer.enqueue(chunk);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
reset(frame.id, "send_overflow");
|
|
206
|
+
response.destroy();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
response.on("end", () => writer.end());
|
|
210
|
+
response.on("error", () => reset(frame.id, "response_error"));
|
|
211
|
+
response.on("close", () => {
|
|
212
|
+
if (!response.complete && streams.has(frame.id))
|
|
213
|
+
reset(frame.id, "response_disconnected");
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
const stream = {
|
|
217
|
+
request,
|
|
218
|
+
sender: null,
|
|
219
|
+
requestBytes: 0,
|
|
220
|
+
receiveCredit: INITIAL_CREDIT,
|
|
221
|
+
consumed: 0,
|
|
222
|
+
drainArmed: false,
|
|
223
|
+
ended: false,
|
|
224
|
+
};
|
|
225
|
+
streams.set(frame.id, stream);
|
|
226
|
+
request.on("error", () => {
|
|
227
|
+
if (streams.has(frame.id))
|
|
228
|
+
reset(frame.id, "request_failed");
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
else if (frame.op === 3 /* Op.DATA */) {
|
|
232
|
+
const stream = streams.get(frame.id);
|
|
233
|
+
if (!stream)
|
|
234
|
+
return;
|
|
235
|
+
stream.requestBytes += frame.payload.byteLength;
|
|
236
|
+
stream.receiveCredit -= frame.payload.byteLength;
|
|
237
|
+
if (stream.ended ||
|
|
238
|
+
stream.requestBytes > MAX_UPLOAD ||
|
|
239
|
+
stream.receiveCredit < 0) {
|
|
240
|
+
reset(frame.id, "request_limit");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (stream.request.write(frame.payload)) {
|
|
244
|
+
stream.receiveCredit += frame.payload.byteLength;
|
|
245
|
+
send(creditFrame(frame.id, frame.payload.byteLength));
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
stream.consumed += frame.payload.byteLength;
|
|
249
|
+
if (!stream.drainArmed) {
|
|
250
|
+
stream.drainArmed = true;
|
|
251
|
+
stream.request.once("drain", () => {
|
|
252
|
+
stream.drainArmed = false;
|
|
253
|
+
if (!streams.has(frame.id))
|
|
254
|
+
return;
|
|
255
|
+
stream.receiveCredit += stream.consumed;
|
|
256
|
+
send(creditFrame(frame.id, stream.consumed));
|
|
257
|
+
stream.consumed = 0;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
else if (frame.op === 4 /* Op.FIN */) {
|
|
263
|
+
const stream = streams.get(frame.id);
|
|
264
|
+
if (!stream || stream.ended)
|
|
265
|
+
return;
|
|
266
|
+
stream.ended = true;
|
|
267
|
+
stream.request.end();
|
|
268
|
+
}
|
|
269
|
+
else if (frame.op === 6 /* Op.CREDIT */) {
|
|
270
|
+
streams.get(frame.id)?.sender?.addCredit(readCredit(frame.payload));
|
|
271
|
+
}
|
|
272
|
+
else if (frame.op === 5 /* Op.RESET */) {
|
|
273
|
+
abort(frame.id);
|
|
274
|
+
}
|
|
275
|
+
else
|
|
276
|
+
throw Error("Unexpected RTC frame");
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
cleanup();
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
channel.onClosed(cleanup);
|
|
283
|
+
channel.onError(cleanup);
|
|
284
|
+
});
|
|
285
|
+
try {
|
|
286
|
+
const answer = await new Promise((resolve, reject) => {
|
|
287
|
+
const timeout = setTimeout(() => reject(Error("RTC answer timeout")), 12_000);
|
|
288
|
+
const finish = () => {
|
|
289
|
+
const description = pc.localDescription();
|
|
290
|
+
if (!description || description.type !== "answer")
|
|
291
|
+
return;
|
|
292
|
+
clearTimeout(timeout);
|
|
293
|
+
resolve({ type: "answer", sdp: description.sdp });
|
|
294
|
+
};
|
|
295
|
+
pc.onGatheringStateChange((state) => {
|
|
296
|
+
if (state === "complete")
|
|
297
|
+
finish();
|
|
298
|
+
});
|
|
299
|
+
pc.onLocalDescription(() => {
|
|
300
|
+
if (pc.gatheringState() === "complete")
|
|
301
|
+
finish();
|
|
302
|
+
});
|
|
303
|
+
pc.setRemoteDescription(parsed.data.sdp, "offer");
|
|
304
|
+
});
|
|
305
|
+
if (closed)
|
|
306
|
+
throw Error("RTC negotiation ended");
|
|
307
|
+
res.json(answer);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
cleanup();
|
|
311
|
+
if (!res.headersSent)
|
|
312
|
+
res.status(503).json({ error: "rtc_negotiation_failed" });
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
return router;
|
|
316
|
+
}
|
|
@@ -13,7 +13,7 @@ export function createRuntimeApiRouter(application, eventBus, runtimeStatus) {
|
|
|
13
13
|
throw new ChatRoomError("INVALID_INPUT", "enabled must be a boolean");
|
|
14
14
|
res.json(application.mcpTools.setEnabled(requireString(req.params.toolName, "toolName"), body.enabled));
|
|
15
15
|
});
|
|
16
|
-
router.get("/events", (
|
|
16
|
+
router.get("/events", (_req, res) => {
|
|
17
17
|
res.status(200);
|
|
18
18
|
res.setHeader("Content-Type", "text/event-stream");
|
|
19
19
|
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
@@ -25,7 +25,7 @@ export function createRuntimeApiRouter(application, eventBus, runtimeStatus) {
|
|
|
25
25
|
});
|
|
26
26
|
const keepalive = setInterval(() => res.write(": keepalive\n\n"), 15000);
|
|
27
27
|
keepalive.unref();
|
|
28
|
-
|
|
28
|
+
res.on("close", () => {
|
|
29
29
|
clearInterval(keepalive);
|
|
30
30
|
unsubscribe();
|
|
31
31
|
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { Router } from "express";
|
|
4
|
+
import { ChatRoomError } from "#core/errors/chatroom-error";
|
|
5
|
+
import { asyncRoute, bodyRecord, requireString, } from "#presentation/http/http-utils";
|
|
6
|
+
import { defaultTerminalShell, defaultTerminalArgs, } from "#plugins/process/infrastructure/terminal-shell";
|
|
7
|
+
const MAX_RUNNING = 8;
|
|
8
|
+
const MAX_SESSIONS = 24;
|
|
9
|
+
const MAX_INPUT_BYTES = 16 * 1024;
|
|
10
|
+
const MAX_BACKLOG = 512 * 1024;
|
|
11
|
+
const validSize = (n) => Number.isInteger(n) && n >= 2 && n <= 500;
|
|
12
|
+
/** PTY I/O over the existing V2 HTTP stream transport; no extra socket/protocol. */
|
|
13
|
+
export function createTerminalApiRouter(app) {
|
|
14
|
+
const router = Router();
|
|
15
|
+
const sessions = new Map();
|
|
16
|
+
const describe = (s) => ({
|
|
17
|
+
...s,
|
|
18
|
+
state: app.processes.read(s.processId).state,
|
|
19
|
+
});
|
|
20
|
+
function requireSession(id) {
|
|
21
|
+
const session = sessions.get(id);
|
|
22
|
+
if (!session)
|
|
23
|
+
throw new ChatRoomError("NOT_FOUND", "Terminal not found");
|
|
24
|
+
return session;
|
|
25
|
+
}
|
|
26
|
+
router.get("/terminal/sessions", (_req, res) => {
|
|
27
|
+
res.json([...sessions.values()].reverse().map(describe));
|
|
28
|
+
});
|
|
29
|
+
router.post("/terminal/sessions", asyncRoute(async (req, res) => {
|
|
30
|
+
const body = bodyRecord(req.body);
|
|
31
|
+
const cols = body.cols ?? 80;
|
|
32
|
+
const rows = body.rows ?? 24;
|
|
33
|
+
if (!validSize(cols) || !validSize(rows))
|
|
34
|
+
throw new ChatRoomError("INVALID_INPUT", "Invalid terminal dimensions");
|
|
35
|
+
const running = [...sessions.values()].filter((s) => app.processes.read(s.processId).state === "running").length;
|
|
36
|
+
if (running >= MAX_RUNNING || sessions.size >= MAX_SESSIONS)
|
|
37
|
+
throw new ChatRoomError("CONFLICT", "Too many terminal sessions");
|
|
38
|
+
const shell = defaultTerminalShell();
|
|
39
|
+
const snapshot = await app.processes.start({
|
|
40
|
+
command: shell,
|
|
41
|
+
args: defaultTerminalArgs(),
|
|
42
|
+
cwd: homedir(),
|
|
43
|
+
pty: true,
|
|
44
|
+
cols,
|
|
45
|
+
rows,
|
|
46
|
+
env: {
|
|
47
|
+
...process.env,
|
|
48
|
+
TERM: "xterm-256color",
|
|
49
|
+
COLORTERM: "truecolor",
|
|
50
|
+
},
|
|
51
|
+
timeoutMs: 0,
|
|
52
|
+
}, { source: "gui", action: "start" });
|
|
53
|
+
const session = {
|
|
54
|
+
id: randomUUID(),
|
|
55
|
+
processId: snapshot.processId,
|
|
56
|
+
shell,
|
|
57
|
+
createdAt: snapshot.startedAt,
|
|
58
|
+
};
|
|
59
|
+
sessions.set(session.id, session);
|
|
60
|
+
res.status(201).json(describe(session));
|
|
61
|
+
}));
|
|
62
|
+
router.post("/terminal/sessions/:id/input", (req, res) => {
|
|
63
|
+
const session = requireSession(requireString(req.params.id, "id"));
|
|
64
|
+
const body = bodyRecord(req.body);
|
|
65
|
+
const data = requireString(body.data, "data");
|
|
66
|
+
if (Buffer.byteLength(data, "utf8") > MAX_INPUT_BYTES)
|
|
67
|
+
throw new ChatRoomError("INVALID_INPUT", "Terminal input too large");
|
|
68
|
+
app.processes.write(session.processId, data);
|
|
69
|
+
res.status(204).end();
|
|
70
|
+
});
|
|
71
|
+
router.post("/terminal/sessions/:id/resize", (req, res) => {
|
|
72
|
+
const session = requireSession(requireString(req.params.id, "id"));
|
|
73
|
+
const body = bodyRecord(req.body);
|
|
74
|
+
if (!validSize(body.cols) || !validSize(body.rows))
|
|
75
|
+
throw new ChatRoomError("INVALID_INPUT", "Invalid terminal dimensions");
|
|
76
|
+
app.processes.resize(session.processId, body.cols, body.rows);
|
|
77
|
+
res.status(204).end();
|
|
78
|
+
});
|
|
79
|
+
router.delete("/terminal/sessions/:id", asyncRoute(async (req, res) => {
|
|
80
|
+
const session = requireSession(requireString(req.params.id, "id"));
|
|
81
|
+
if (app.processes.read(session.processId).state === "running")
|
|
82
|
+
await app.processKill(session.processId, false);
|
|
83
|
+
sessions.delete(session.id);
|
|
84
|
+
res.status(204).end();
|
|
85
|
+
}));
|
|
86
|
+
router.get("/terminal/sessions/:id/stream", (req, res) => {
|
|
87
|
+
const session = requireSession(requireString(req.params.id, "id"));
|
|
88
|
+
res.status(200);
|
|
89
|
+
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
90
|
+
res.setHeader("Cache-Control", "private, no-store, no-transform");
|
|
91
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
92
|
+
res.flushHeaders();
|
|
93
|
+
let finished = false;
|
|
94
|
+
const write = (type, payload) => {
|
|
95
|
+
if (finished || res.destroyed)
|
|
96
|
+
return;
|
|
97
|
+
if (res.writableLength > MAX_BACKLOG) {
|
|
98
|
+
res.destroy(new Error("Terminal stream is too slow"));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
res.write("event: " + type + "\n" + "data: " + JSON.stringify(payload) + "\n\n");
|
|
102
|
+
};
|
|
103
|
+
// Attach before taking snapshot: no output gap between backlog and live bytes.
|
|
104
|
+
const { snapshot, unsubscribe } = app.processes.subscribeOutput(session.processId, (chunk) => {
|
|
105
|
+
if (!chunk) {
|
|
106
|
+
write("exit", describe(session));
|
|
107
|
+
res.end();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
for (let offset = 0; offset < chunk.length; offset += 16 * 1024)
|
|
111
|
+
write("output", chunk.subarray(offset, offset + 16 * 1024).toString("base64"));
|
|
112
|
+
});
|
|
113
|
+
write("snapshot", {
|
|
114
|
+
output: Buffer.from(snapshot.stdout, "utf8").toString("base64"),
|
|
115
|
+
state: snapshot.state,
|
|
116
|
+
});
|
|
117
|
+
if (snapshot.state !== "running") {
|
|
118
|
+
write("exit", describe(session));
|
|
119
|
+
unsubscribe();
|
|
120
|
+
res.end();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const heartbeat = setInterval(() => write("ping", null), 15000);
|
|
124
|
+
heartbeat.unref();
|
|
125
|
+
res.on("close", () => {
|
|
126
|
+
finished = true;
|
|
127
|
+
clearInterval(heartbeat);
|
|
128
|
+
unsubscribe();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
return router;
|
|
132
|
+
}
|
|
@@ -11,7 +11,7 @@ export function createWebPlugin() {
|
|
|
11
11
|
id: "web",
|
|
12
12
|
activate(context) {
|
|
13
13
|
context.services.provide(WebServiceToken, {
|
|
14
|
-
application: new WebRuntime(context.services.require(WorkspaceServiceToken), context.services.require(GitAccessToken), context.operations, context.services.require(ProcessServiceToken), context.services.require(ComputerServiceToken), context.services.require(AgentServiceToken), context.mcpTools),
|
|
14
|
+
application: new WebRuntime(context.services.require(WorkspaceServiceToken), context.services.require(GitAccessToken), context.operations, context.services.require(ProcessServiceToken), context.services.require(ComputerServiceToken), context.services.require(AgentServiceToken), context.mcpTools, context.customMcp),
|
|
15
15
|
});
|
|
16
16
|
},
|
|
17
17
|
};
|
|
@@ -4,6 +4,7 @@ import type { GitAccess } from "#app/git-access";
|
|
|
4
4
|
import type { ProcessSupervisor } from "#plugins/process/process-supervisor";
|
|
5
5
|
import type { WorkspaceService } from "#plugins/workspace/workspace-service";
|
|
6
6
|
import type { AgentRuntime } from "#plugins/agent/runtime";
|
|
7
|
+
import type { CustomMcpManager } from "#mcp/custom/manager";
|
|
7
8
|
import type { McpToolControl } from "#mcp/server/tool-control";
|
|
8
9
|
export declare class WebRuntime {
|
|
9
10
|
readonly workspaces: WorkspaceService;
|
|
@@ -13,7 +14,8 @@ export declare class WebRuntime {
|
|
|
13
14
|
readonly computer: ComputerService;
|
|
14
15
|
readonly agents: AgentRuntime;
|
|
15
16
|
readonly mcpTools: McpToolControl;
|
|
16
|
-
|
|
17
|
+
readonly customMcp: CustomMcpManager;
|
|
18
|
+
constructor(workspaces: WorkspaceService, git: GitAccess, operations: OperationLog, processes: ProcessSupervisor, computer: ComputerService, agents: AgentRuntime, mcpTools: McpToolControl, customMcp: CustomMcpManager);
|
|
17
19
|
processKill(processId: string, force?: boolean): Promise<import("./api-types.js").ProcessSnapshot>;
|
|
18
20
|
listProcesses(): import("./api-types.js").ProcessSummary[];
|
|
19
21
|
getProcess(processId: string): import("./api-types.js").ProcessSnapshot;
|
|
@@ -6,7 +6,8 @@ export class WebRuntime {
|
|
|
6
6
|
computer;
|
|
7
7
|
agents;
|
|
8
8
|
mcpTools;
|
|
9
|
-
|
|
9
|
+
customMcp;
|
|
10
|
+
constructor(workspaces, git, operations, processes, computer, agents, mcpTools, customMcp) {
|
|
10
11
|
this.workspaces = workspaces;
|
|
11
12
|
this.git = git;
|
|
12
13
|
this.operations = operations;
|
|
@@ -14,6 +15,7 @@ export class WebRuntime {
|
|
|
14
15
|
this.computer = computer;
|
|
15
16
|
this.agents = agents;
|
|
16
17
|
this.mcpTools = mcpTools;
|
|
18
|
+
this.customMcp = customMcp;
|
|
17
19
|
}
|
|
18
20
|
processKill(processId, force = false) {
|
|
19
21
|
return this.operations.run({
|