@rynx-ai/server 0.1.0 → 0.1.10-beta.2
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/browser-surface-control-queue.d.ts +42 -0
- package/dist/browser-surface-control-queue.js +187 -0
- package/dist/browser-surface-ws.d.ts +16 -0
- package/dist/browser-surface-ws.js +356 -0
- package/dist/control-api.d.ts +76 -4
- package/dist/control-api.js +3014 -133
- package/dist/control-web/assets/highlighted-body-OFNGDK62-DHu2g-rk.js +1 -0
- package/dist/control-web/assets/index-B6Pb5j9M.js +666 -0
- package/dist/control-web/assets/index-BU-_Qud_.css +32 -0
- package/dist/control-web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/dist/control-web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/dist/control-web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/dist/control-web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/dist/control-web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/dist/control-web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist/control-web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/dist/control-web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/control-web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/control-web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/control-web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/control-web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/control-web/assets/mermaid-GHXKKRXX-CSYzOmBR.js +131 -0
- package/dist/control-web/assets/rynx-wordmark-LL1QRYHD.png +0 -0
- package/dist/control-web/favicon.png +0 -0
- package/dist/control-web/favicon.svg +38 -0
- package/dist/control-web/index.html +16 -0
- package/dist/control-web-dist.d.ts +1 -1
- package/dist/control-web-dist.js +19 -14
- package/dist/desktop-browser-host.d.ts +170 -0
- package/dist/desktop-browser-host.js +1018 -0
- package/dist/direct-runtime-browser-inspect-server.d.ts +72 -0
- package/dist/direct-runtime-browser-inspect-server.js +749 -0
- package/dist/direct-runtime-browser-surface-server.d.ts +74 -0
- package/dist/direct-runtime-browser-surface-server.js +821 -0
- package/dist/direct-runtime-server.d.ts +143 -0
- package/dist/direct-runtime-server.js +1959 -0
- package/dist/emulator-surface-ws.d.ts +8 -0
- package/dist/emulator-surface-ws.js +198 -0
- package/dist/machine-session-service.d.ts +265 -0
- package/dist/machine-session-service.js +822 -0
- package/dist/provider-cli-host.d.ts +10 -0
- package/dist/provider-cli-host.js +1 -0
- package/dist/remote-runtime-dispatcher.d.ts +94 -0
- package/dist/remote-runtime-dispatcher.js +403 -0
- package/dist/remote-runtime-session-projection.d.ts +19 -0
- package/dist/remote-runtime-session-projection.js +566 -0
- package/dist/remote-runtime.d.ts +22 -0
- package/dist/remote-runtime.js +32 -0
- package/dist/runtime-web-auth.d.ts +26 -0
- package/dist/runtime-web-auth.js +132 -0
- package/dist/server.d.ts +364 -12
- package/dist/server.js +341 -100
- package/dist/session-browser-service.d.ts +248 -0
- package/dist/session-browser-service.js +772 -0
- package/dist/session-browser-surface-coordinator.d.ts +24 -0
- package/dist/session-browser-surface-coordinator.js +669 -0
- package/dist/session-emulator-service.d.ts +167 -0
- package/dist/session-emulator-service.js +464 -0
- package/dist/session-runtime-index.d.ts +23 -0
- package/dist/session-runtime-index.js +96 -0
- package/dist/session-terminal-host.d.ts +51 -0
- package/dist/session-terminal-host.js +270 -0
- package/dist/terminal-ws.d.ts +12 -20
- package/dist/terminal-ws.js +421 -97
- package/package.json +14 -7
package/dist/terminal-ws.js
CHANGED
|
@@ -1,123 +1,447 @@
|
|
|
1
|
-
import { WebSocketServer } from "ws";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
const
|
|
1
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
2
|
+
import { authorizeRuntimeWebRequest, RUNTIME_WEB_CAPABILITY_QUERY, } from "./runtime-web-auth.js";
|
|
3
|
+
import { SessionTerminalOpenError, } from "./session-terminal-host.js";
|
|
4
|
+
const RUNTIME_TERMINAL_PATH = /^\/api\/runtimes\/([^/]+)\/sessions\/([^/]+)\/terminal$/;
|
|
5
|
+
const LEGACY_TERMINAL_PATH = /^\/api\/sessions\/([^/]+)\/terminal$/;
|
|
6
|
+
const SELECTOR_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,255}$/;
|
|
7
|
+
const MAX_DIMENSION = 1_000;
|
|
8
|
+
const MAX_BROWSER_MESSAGE_BYTES = 64 * 1024;
|
|
9
|
+
const MAX_PENDING_BROWSER_MESSAGES = 32;
|
|
10
|
+
const MAX_PENDING_BROWSER_BYTES = 128 * 1024;
|
|
11
|
+
const MAX_BROWSER_BUFFERED_BYTES = 1024 * 1024;
|
|
12
|
+
const INTERACTIVE_OUTPUT_CHUNK_BYTES = 2 * 1024;
|
|
13
|
+
const NORMAL_OUTPUT_CHUNK_BYTES = 16 * 1024;
|
|
13
14
|
const INTERACTIVE_ECHO_WINDOW_MS = 750;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const WS_CLOSE_TERMINAL_GONE = 4404;
|
|
15
|
+
const WS_CLOSE_BAD_REQUEST = 4400;
|
|
16
|
+
const WS_CLOSE_UNAUTHORIZED = 4403;
|
|
17
|
+
/** Reserved exclusively for an attachment that was not live at open time. */
|
|
18
|
+
const WS_CLOSE_TERMINAL_NOT_LIVE = 4404;
|
|
19
|
+
const WS_CLOSE_TERMINAL_EXITED = 4405;
|
|
20
|
+
const WS_CLOSE_RUNTIME_UNAVAILABLE = 4406;
|
|
21
|
+
const WS_CLOSE_BACKPRESSURE = 4408;
|
|
22
22
|
const WS_CLOSE_INTERNAL_ERROR = 4500;
|
|
23
|
-
/** Attach the
|
|
23
|
+
/** Attach the authenticated Runtime-scoped terminal bridge to an HTTP server. */
|
|
24
24
|
export function attachTerminalWs(server, deps) {
|
|
25
|
-
const wss = new WebSocketServer({
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
const wss = new WebSocketServer({
|
|
26
|
+
noServer: true,
|
|
27
|
+
perMessageDeflate: false,
|
|
28
|
+
maxPayload: MAX_BROWSER_MESSAGE_BYTES,
|
|
29
|
+
});
|
|
30
|
+
server.on("upgrade", (request, socket, head) => {
|
|
31
|
+
const parsed = parseTerminalUpgrade(request);
|
|
32
|
+
if (!parsed)
|
|
33
|
+
return;
|
|
34
|
+
if ("failure" in parsed) {
|
|
35
|
+
rejectUpgrade(socket, 400, parsed.failure);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const { url, route } = parsed;
|
|
39
|
+
const authorization = authorizeRuntimeWebRequest(request, {
|
|
40
|
+
mutation: true,
|
|
41
|
+
capability: url.searchParams.get(RUNTIME_WEB_CAPABILITY_QUERY) ?? "",
|
|
42
|
+
access: deps.runtimeWebAccess,
|
|
43
|
+
});
|
|
44
|
+
if (authorization) {
|
|
45
|
+
rejectUpgrade(socket, authorization.status, authorization.error);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
28
48
|
try {
|
|
29
|
-
|
|
49
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
50
|
+
void handleConnection(ws, url, route, deps);
|
|
51
|
+
});
|
|
30
52
|
}
|
|
31
53
|
catch {
|
|
32
|
-
|
|
54
|
+
rejectUpgrade(socket, 400, "invalid terminal WebSocket upgrade");
|
|
33
55
|
}
|
|
34
|
-
const match = TERMINAL_PATH.exec(url.pathname);
|
|
35
|
-
if (!match)
|
|
36
|
-
return; // Not our route — leave it for any other upgrade handler.
|
|
37
|
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
38
|
-
handleConnection(ws, url, decodeURIComponent(match[1]), deps);
|
|
39
|
-
});
|
|
40
56
|
});
|
|
41
57
|
return wss;
|
|
42
58
|
}
|
|
43
|
-
function
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
};
|
|
64
|
-
// Monotonic-ish stamp of the last client keystroke, so a redraw right after
|
|
65
|
-
// input takes the smaller interactive frame cap (omnigent `last_client_input_at`).
|
|
59
|
+
async function handleConnection(ws, url, route, deps) {
|
|
60
|
+
let role;
|
|
61
|
+
let cols;
|
|
62
|
+
let rows;
|
|
63
|
+
try {
|
|
64
|
+
role = queryRole(url.searchParams.get("role"));
|
|
65
|
+
cols = queryDimension(url.searchParams.get("cols"), 80, "cols");
|
|
66
|
+
rows = queryDimension(url.searchParams.get("rows"), 24, "rows");
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
closeBrowser(ws, WS_CLOSE_BAD_REQUEST, "invalid terminal options", error instanceof Error ? error.message : "invalid terminal options");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
const pendingInputs = [];
|
|
74
|
+
let pendingInputBytes = 0;
|
|
75
|
+
let lease;
|
|
76
|
+
let terminal;
|
|
77
|
+
let browserClosed = false;
|
|
78
|
+
let cleanupPromise;
|
|
66
79
|
let lastClientInputAt = 0;
|
|
67
|
-
|
|
68
|
-
let
|
|
69
|
-
let
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (
|
|
73
|
-
|
|
80
|
+
let liveInputTail = Promise.resolve();
|
|
81
|
+
let liveInputFrames = 0;
|
|
82
|
+
let liveInputBytes = 0;
|
|
83
|
+
let inputReady = false;
|
|
84
|
+
const cleanup = () => {
|
|
85
|
+
if (cleanupPromise)
|
|
86
|
+
return cleanupPromise;
|
|
87
|
+
browserClosed = true;
|
|
88
|
+
controller.abort(new Error("Browser terminal disconnected"));
|
|
89
|
+
pendingInputs.length = 0;
|
|
90
|
+
pendingInputBytes = 0;
|
|
91
|
+
const opened = terminal;
|
|
92
|
+
terminal = undefined;
|
|
93
|
+
const acquired = lease;
|
|
94
|
+
lease = undefined;
|
|
95
|
+
cleanupPromise = (async () => {
|
|
96
|
+
try {
|
|
97
|
+
await opened?.close();
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
acquired?.release();
|
|
101
|
+
}
|
|
102
|
+
})();
|
|
103
|
+
return cleanupPromise;
|
|
104
|
+
};
|
|
105
|
+
const keepAliveMs = deps.keepAliveMs ?? 25_000;
|
|
106
|
+
const keepalive = keepAliveMs > 0
|
|
107
|
+
? setInterval(() => {
|
|
108
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
109
|
+
ws.ping();
|
|
110
|
+
}, keepAliveMs)
|
|
111
|
+
: undefined;
|
|
112
|
+
keepalive?.unref?.();
|
|
113
|
+
ws.on("message", (data, isBinary) => {
|
|
114
|
+
if (browserClosed)
|
|
74
115
|
return;
|
|
116
|
+
let input;
|
|
117
|
+
try {
|
|
118
|
+
input = parseBrowserInput(data, isBinary);
|
|
75
119
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
: WS_COALESCE_MAX_BYTES;
|
|
81
|
-
for (let off = 0; off < merged.length; off += limit) {
|
|
82
|
-
ws.send(merged.subarray(off, Math.min(off + limit, merged.length)));
|
|
120
|
+
catch (error) {
|
|
121
|
+
closeBrowser(ws, WS_CLOSE_BAD_REQUEST, "invalid terminal input", error instanceof Error ? error.message : "invalid terminal input");
|
|
122
|
+
void cleanup();
|
|
123
|
+
return;
|
|
83
124
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
125
|
+
if (input.type === "input")
|
|
126
|
+
lastClientInputAt = Date.now();
|
|
127
|
+
const bytes = input.type === "input" ? input.data.byteLength : 16;
|
|
128
|
+
const opened = terminal;
|
|
129
|
+
if (opened && inputReady) {
|
|
130
|
+
if (liveInputFrames >= MAX_PENDING_BROWSER_MESSAGES ||
|
|
131
|
+
liveInputBytes + bytes > MAX_PENDING_BROWSER_BYTES) {
|
|
132
|
+
closeBrowser(ws, WS_CLOSE_BACKPRESSURE, "terminal input queue full", "Terminal input exceeded its bounded live queue");
|
|
133
|
+
void cleanup();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
liveInputFrames += 1;
|
|
137
|
+
liveInputBytes += bytes;
|
|
138
|
+
const operation = liveInputTail.then(() => applyBrowserInput(opened, input));
|
|
139
|
+
liveInputTail = operation
|
|
140
|
+
.catch((error) => {
|
|
141
|
+
closeForError(ws, error);
|
|
142
|
+
void cleanup();
|
|
143
|
+
})
|
|
144
|
+
.finally(() => {
|
|
145
|
+
liveInputFrames -= 1;
|
|
146
|
+
liveInputBytes -= bytes;
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
90
149
|
}
|
|
150
|
+
if (pendingInputs.length >= MAX_PENDING_BROWSER_MESSAGES ||
|
|
151
|
+
pendingInputBytes + bytes > MAX_PENDING_BROWSER_BYTES) {
|
|
152
|
+
closeBrowser(ws, WS_CLOSE_BACKPRESSURE, "terminal input queue full", "Terminal input exceeded its bounded pre-open queue");
|
|
153
|
+
void cleanup();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
pendingInputs.push({ input, bytes });
|
|
157
|
+
pendingInputBytes += bytes;
|
|
91
158
|
});
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
159
|
+
ws.on("close", () => {
|
|
160
|
+
if (keepalive)
|
|
161
|
+
clearInterval(keepalive);
|
|
162
|
+
void cleanup();
|
|
96
163
|
});
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
164
|
+
ws.on("error", () => {
|
|
165
|
+
if (keepalive)
|
|
166
|
+
clearInterval(keepalive);
|
|
167
|
+
void cleanup();
|
|
100
168
|
});
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
169
|
+
try {
|
|
170
|
+
let opening;
|
|
171
|
+
if (route.legacy) {
|
|
172
|
+
if (!deps.legacyLocalTerminalHost) {
|
|
173
|
+
throw new SessionTerminalOpenError("method_not_found", "Legacy local Session terminals are unavailable");
|
|
174
|
+
}
|
|
175
|
+
opening = deps.legacyLocalTerminalHost.open(route.sessionId, {
|
|
176
|
+
role,
|
|
177
|
+
cols,
|
|
178
|
+
rows,
|
|
179
|
+
signal: controller.signal,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
if (!deps.runtimeConnectionResolver) {
|
|
184
|
+
throw new SessionTerminalOpenError("method_not_found", "Runtime-scoped Session terminals are unavailable");
|
|
185
|
+
}
|
|
186
|
+
const acquired = await deps.runtimeConnectionResolver.acquire(route.selector, {
|
|
187
|
+
signal: controller.signal,
|
|
188
|
+
});
|
|
189
|
+
if (browserClosed || controller.signal.aborted) {
|
|
190
|
+
acquired.release();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
lease = acquired;
|
|
194
|
+
opening = acquired.openSessionTerminal(route.sessionId, {
|
|
195
|
+
role,
|
|
196
|
+
cols,
|
|
197
|
+
rows,
|
|
198
|
+
signal: controller.signal,
|
|
199
|
+
timeoutMs: 10_000,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
const opened = await opening;
|
|
203
|
+
if (browserClosed) {
|
|
204
|
+
await opened.close().catch(() => undefined);
|
|
105
205
|
return;
|
|
106
206
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
207
|
+
terminal = opened;
|
|
208
|
+
sendText(ws, { t: "opened", role: opened.role });
|
|
209
|
+
while (pendingInputs.length > 0) {
|
|
210
|
+
const batch = pendingInputs.splice(0);
|
|
211
|
+
for (const queued of batch) {
|
|
212
|
+
pendingInputBytes -= queued.bytes;
|
|
213
|
+
await applyBrowserInput(opened, queued.input);
|
|
111
214
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
215
|
+
}
|
|
216
|
+
pendingInputBytes = 0;
|
|
217
|
+
inputReady = true;
|
|
218
|
+
for await (const output of opened) {
|
|
219
|
+
if (browserClosed)
|
|
220
|
+
return;
|
|
221
|
+
const chunkSize = Date.now() - lastClientInputAt < INTERACTIVE_ECHO_WINDOW_MS
|
|
222
|
+
? INTERACTIVE_OUTPUT_CHUNK_BYTES
|
|
223
|
+
: NORMAL_OUTPUT_CHUNK_BYTES;
|
|
224
|
+
for (let offset = 0; offset < output.byteLength; offset += chunkSize) {
|
|
225
|
+
await sendBinary(ws, output.subarray(offset, Math.min(offset + chunkSize, output.byteLength)));
|
|
115
226
|
}
|
|
116
227
|
}
|
|
117
|
-
|
|
118
|
-
|
|
228
|
+
if (browserClosed)
|
|
229
|
+
return;
|
|
230
|
+
const closed = await opened.closed;
|
|
231
|
+
closeForTerminalEnd(ws, closed.reason);
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
if (!browserClosed)
|
|
235
|
+
closeForError(ws, error);
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
if (keepalive)
|
|
239
|
+
clearInterval(keepalive);
|
|
240
|
+
await cleanup().catch(() => undefined);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function parseTerminalUpgrade(request) {
|
|
244
|
+
let url;
|
|
245
|
+
try {
|
|
246
|
+
url = new URL(request.url ?? "", "http://localhost");
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
const runtimeMatch = RUNTIME_TERMINAL_PATH.exec(url.pathname);
|
|
252
|
+
const legacyMatch = LEGACY_TERMINAL_PATH.exec(url.pathname);
|
|
253
|
+
if (!runtimeMatch && !legacyMatch)
|
|
254
|
+
return undefined;
|
|
255
|
+
try {
|
|
256
|
+
if (runtimeMatch) {
|
|
257
|
+
const selector = decodeURIComponent(runtimeMatch[1]);
|
|
258
|
+
const sessionId = decodeURIComponent(runtimeMatch[2]);
|
|
259
|
+
if (!SELECTOR_PATTERN.test(selector) || !validSessionId(sessionId)) {
|
|
260
|
+
return { failure: "invalid terminal route" };
|
|
261
|
+
}
|
|
262
|
+
return { url, route: { selector, sessionId, legacy: false } };
|
|
119
263
|
}
|
|
264
|
+
const sessionId = decodeURIComponent(legacyMatch[1]);
|
|
265
|
+
if (!validSessionId(sessionId))
|
|
266
|
+
return { failure: "invalid terminal route" };
|
|
267
|
+
return { url, route: { selector: "local", sessionId, legacy: true } };
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return { failure: "invalid terminal route encoding" };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function parseBrowserInput(data, isBinary) {
|
|
274
|
+
const bytes = rawDataBytes(data);
|
|
275
|
+
if (bytes.byteLength === 0 || bytes.byteLength > MAX_BROWSER_MESSAGE_BYTES) {
|
|
276
|
+
throw new Error("Terminal input frame size is invalid");
|
|
277
|
+
}
|
|
278
|
+
if (isBinary)
|
|
279
|
+
return { type: "input", data: bytes };
|
|
280
|
+
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
281
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
282
|
+
throw new Error("Terminal control frame must be an object");
|
|
283
|
+
}
|
|
284
|
+
const frame = value;
|
|
285
|
+
if (frame.t === "resize" &&
|
|
286
|
+
Object.keys(frame).every((key) => key === "t" || key === "cols" || key === "rows") &&
|
|
287
|
+
validDimension(frame.cols) &&
|
|
288
|
+
validDimension(frame.rows)) {
|
|
289
|
+
return { type: "resize", cols: frame.cols, rows: frame.rows };
|
|
290
|
+
}
|
|
291
|
+
if (frame.t === "input" &&
|
|
292
|
+
Object.keys(frame).every((key) => key === "t" || key === "data") &&
|
|
293
|
+
typeof frame.data === "string" &&
|
|
294
|
+
frame.data.length > 0 &&
|
|
295
|
+
Buffer.byteLength(frame.data, "utf8") <= MAX_BROWSER_MESSAGE_BYTES) {
|
|
296
|
+
return { type: "input", data: Uint8Array.from(Buffer.from(frame.data, "utf8")) };
|
|
297
|
+
}
|
|
298
|
+
throw new Error("Unknown or invalid terminal control frame");
|
|
299
|
+
}
|
|
300
|
+
async function applyBrowserInput(terminal, input) {
|
|
301
|
+
if (input.type === "input") {
|
|
302
|
+
// Defense in depth: tmux -r also blocks writes, but bytes never reach it.
|
|
303
|
+
if (terminal.role === "owner")
|
|
304
|
+
await terminal.write(input.data);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
await terminal.resize(input.cols, input.rows);
|
|
308
|
+
}
|
|
309
|
+
async function sendBinary(ws, data) {
|
|
310
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
311
|
+
throw transportError("Browser terminal is closed");
|
|
312
|
+
if (ws.bufferedAmount + data.byteLength > MAX_BROWSER_BUFFERED_BYTES) {
|
|
313
|
+
const error = new Error("Browser terminal output exceeded its bounded buffer");
|
|
314
|
+
error.name = "BackpressureError";
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
await new Promise((resolve, reject) => {
|
|
318
|
+
ws.send(data, { binary: true, compress: false }, (error) => {
|
|
319
|
+
if (error)
|
|
320
|
+
reject(transportError(error.message));
|
|
321
|
+
else
|
|
322
|
+
resolve();
|
|
323
|
+
});
|
|
120
324
|
});
|
|
121
|
-
|
|
122
|
-
|
|
325
|
+
}
|
|
326
|
+
function sendText(ws, value) {
|
|
327
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
328
|
+
ws.send(JSON.stringify(value), { binary: false, compress: false });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function closeForTerminalEnd(ws, reason) {
|
|
332
|
+
if (reason === "client_closed")
|
|
333
|
+
return;
|
|
334
|
+
if (reason === "terminal_exited") {
|
|
335
|
+
closeBrowser(ws, WS_CLOSE_TERMINAL_EXITED, "terminal exited");
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (reason === "backpressure" || reason === "capacity") {
|
|
339
|
+
closeBrowser(ws, WS_CLOSE_BACKPRESSURE, "terminal backpressure");
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (reason === "unauthorized") {
|
|
343
|
+
closeBrowser(ws, WS_CLOSE_UNAUTHORIZED, "terminal authorization revoked");
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
closeBrowser(ws, WS_CLOSE_INTERNAL_ERROR, "terminal bridge error");
|
|
347
|
+
}
|
|
348
|
+
function closeForError(ws, error) {
|
|
349
|
+
const code = errorCode(error);
|
|
350
|
+
const message = error instanceof Error ? error.message : "Terminal bridge failed";
|
|
351
|
+
if (code === "not_live") {
|
|
352
|
+
closeBrowser(ws, WS_CLOSE_TERMINAL_NOT_LIVE, "terminal not live");
|
|
353
|
+
}
|
|
354
|
+
else if (code === "unauthorized" || code === "authentication_failed") {
|
|
355
|
+
closeBrowser(ws, WS_CLOSE_UNAUTHORIZED, "terminal authorization failed");
|
|
356
|
+
}
|
|
357
|
+
else if (code === "method_not_found" || code === "incompatible" || code === "not_found") {
|
|
358
|
+
closeBrowser(ws, WS_CLOSE_RUNTIME_UNAVAILABLE, "terminal runtime unavailable");
|
|
359
|
+
}
|
|
360
|
+
else if (code === "capacity" || errorName(error) === "BackpressureError") {
|
|
361
|
+
closeBrowser(ws, WS_CLOSE_BACKPRESSURE, "terminal backpressure");
|
|
362
|
+
}
|
|
363
|
+
else if (code === "unreachable" ||
|
|
364
|
+
code === "closed" ||
|
|
365
|
+
code === "deadline_exceeded" ||
|
|
366
|
+
errorName(error) === "AbortError" ||
|
|
367
|
+
errorName(error) === "TransportError") {
|
|
368
|
+
closeBrowser(ws, 1012, "terminal transport unavailable");
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
sendText(ws, { t: "error", message });
|
|
372
|
+
closeBrowser(ws, WS_CLOSE_INTERNAL_ERROR, "terminal bridge error");
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function closeBrowser(ws, code, reason, message) {
|
|
376
|
+
if (message)
|
|
377
|
+
sendText(ws, { t: "error", message });
|
|
378
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
379
|
+
ws.close(code, boundedCloseReason(reason));
|
|
380
|
+
}
|
|
381
|
+
function errorCode(error) {
|
|
382
|
+
return error && typeof error === "object" && "code" in error ? error.code : undefined;
|
|
383
|
+
}
|
|
384
|
+
function errorName(error) {
|
|
385
|
+
return error && typeof error === "object" && "name" in error ? error.name : undefined;
|
|
386
|
+
}
|
|
387
|
+
function transportError(message) {
|
|
388
|
+
const error = new Error(message);
|
|
389
|
+
error.name = "TransportError";
|
|
390
|
+
return error;
|
|
391
|
+
}
|
|
392
|
+
function queryDimension(raw, fallback, field) {
|
|
393
|
+
if (raw === null)
|
|
394
|
+
return fallback;
|
|
395
|
+
const value = Number(raw);
|
|
396
|
+
if (!validDimension(value)) {
|
|
397
|
+
throw new Error(`Terminal ${field} must be an integer within 1..${MAX_DIMENSION}`);
|
|
398
|
+
}
|
|
399
|
+
return value;
|
|
400
|
+
}
|
|
401
|
+
function queryRole(raw) {
|
|
402
|
+
if (raw === null || raw === "owner")
|
|
403
|
+
return "owner";
|
|
404
|
+
if (raw === "read-only")
|
|
405
|
+
return "read-only";
|
|
406
|
+
throw new Error("Terminal role must be owner or read-only");
|
|
407
|
+
}
|
|
408
|
+
function validDimension(value) {
|
|
409
|
+
return Number.isSafeInteger(value) && value >= 1 && value <= MAX_DIMENSION;
|
|
410
|
+
}
|
|
411
|
+
function validSessionId(value) {
|
|
412
|
+
return value.length > 0 && value.length <= 256 && !/[\u0000-\u001f\u007f]/.test(value);
|
|
413
|
+
}
|
|
414
|
+
function rawDataBytes(data) {
|
|
415
|
+
if (Buffer.isBuffer(data))
|
|
416
|
+
return Uint8Array.from(data);
|
|
417
|
+
if (data instanceof ArrayBuffer)
|
|
418
|
+
return new Uint8Array(data.slice(0));
|
|
419
|
+
if (Array.isArray(data))
|
|
420
|
+
return Uint8Array.from(Buffer.concat(data));
|
|
421
|
+
return Uint8Array.from(Buffer.from(data));
|
|
422
|
+
}
|
|
423
|
+
function rejectUpgrade(socket, status, message) {
|
|
424
|
+
if (socket.destroyed)
|
|
425
|
+
return;
|
|
426
|
+
const body = `${message}\n`;
|
|
427
|
+
socket.end(`HTTP/1.1 ${status} ${status === 401 ? "Unauthorized" : status === 403 ? "Forbidden" : "Bad Request"}\r\n` +
|
|
428
|
+
"Connection: close\r\n" +
|
|
429
|
+
"Content-Type: text/plain; charset=utf-8\r\n" +
|
|
430
|
+
`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n` +
|
|
431
|
+
"Cache-Control: no-store\r\n\r\n" +
|
|
432
|
+
body);
|
|
433
|
+
}
|
|
434
|
+
function boundedCloseReason(reason) {
|
|
435
|
+
if (Buffer.byteLength(reason, "utf8") <= 123)
|
|
436
|
+
return reason;
|
|
437
|
+
let bounded = "";
|
|
438
|
+
let bytes = 0;
|
|
439
|
+
for (const character of reason) {
|
|
440
|
+
const characterBytes = Buffer.byteLength(character, "utf8");
|
|
441
|
+
if (bytes + characterBytes > 123)
|
|
442
|
+
break;
|
|
443
|
+
bounded += character;
|
|
444
|
+
bytes += characterBytes;
|
|
445
|
+
}
|
|
446
|
+
return bounded;
|
|
123
447
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rynx-ai/server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10-beta.2",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/rynx-ai/rynx.git",
|
|
7
|
+
"directory": "packages/server"
|
|
8
|
+
},
|
|
4
9
|
"description": "Composition root: wires the @rynx-ai/runtime host to channel plugins (injected by the daemon) and exposes a liveness HTTP probe.",
|
|
5
10
|
"type": "module",
|
|
6
11
|
"publishConfig": {
|
|
@@ -22,16 +27,18 @@
|
|
|
22
27
|
"@koa/router": "^15.4.0",
|
|
23
28
|
"koa": "^3.2.0",
|
|
24
29
|
"ws": "^8.21.0",
|
|
25
|
-
"@rynx-ai/
|
|
26
|
-
"@rynx-ai/
|
|
27
|
-
"@rynx-ai/core": "0.1.
|
|
28
|
-
"@rynx-ai/
|
|
30
|
+
"@rynx-ai/plugin-sdk": "0.1.10-beta.2",
|
|
31
|
+
"@rynx-ai/protocol": "0.1.10-beta.2",
|
|
32
|
+
"@rynx-ai/core": "0.1.10-beta.2",
|
|
33
|
+
"@rynx-ai/runtime": "0.1.10-beta.2",
|
|
34
|
+
"@rynx-ai/remote-runtime-e2ee": "0.1.10-beta.2"
|
|
29
35
|
},
|
|
30
36
|
"devDependencies": {
|
|
31
|
-
"@types/ws": "^8.18.1"
|
|
37
|
+
"@types/ws": "^8.18.1",
|
|
38
|
+
"@rynx-ai/control-web": "0.1.10-beta.2"
|
|
32
39
|
},
|
|
33
40
|
"scripts": {
|
|
34
|
-
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
41
|
+
"build": "rm -rf dist && tsc -p tsconfig.json && mkdir -p dist/control-web && cp -R ../control-web/dist/. dist/control-web/",
|
|
35
42
|
"start": "node dist/server.js",
|
|
36
43
|
"dev": "tsx watch --tsconfig ../../tsconfig.json src/server.ts"
|
|
37
44
|
}
|