@rynx-ai/server 0.1.0 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/browser-surface-control-queue.d.ts +42 -0
  2. package/dist/browser-surface-control-queue.js +187 -0
  3. package/dist/browser-surface-ws.d.ts +14 -0
  4. package/dist/browser-surface-ws.js +355 -0
  5. package/dist/control-api.d.ts +59 -4
  6. package/dist/control-api.js +2213 -132
  7. package/dist/control-web/assets/highlighted-body-OFNGDK62-e-w61YLy.js +1 -0
  8. package/dist/control-web/assets/index-BQ7XVBKO.css +32 -0
  9. package/dist/control-web/assets/index-ClpzuBJx.js +606 -0
  10. package/dist/control-web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  11. package/dist/control-web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  12. package/dist/control-web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  13. package/dist/control-web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  14. package/dist/control-web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  15. package/dist/control-web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  16. package/dist/control-web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  17. package/dist/control-web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  18. package/dist/control-web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  19. package/dist/control-web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  20. package/dist/control-web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  21. package/dist/control-web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  22. package/dist/control-web/assets/mermaid-GHXKKRXX-DZn7Hbr2.js +131 -0
  23. package/dist/control-web/assets/rynx-wordmark-LL1QRYHD.png +0 -0
  24. package/dist/control-web/favicon.png +0 -0
  25. package/dist/control-web/favicon.svg +38 -0
  26. package/dist/control-web/index.html +16 -0
  27. package/dist/control-web-dist.d.ts +1 -1
  28. package/dist/control-web-dist.js +19 -14
  29. package/dist/desktop-browser-host.d.ts +153 -0
  30. package/dist/desktop-browser-host.js +936 -0
  31. package/dist/direct-runtime-browser-surface-server.d.ts +74 -0
  32. package/dist/direct-runtime-browser-surface-server.js +821 -0
  33. package/dist/direct-runtime-server.d.ts +137 -0
  34. package/dist/direct-runtime-server.js +1931 -0
  35. package/dist/emulator-surface-ws.d.ts +6 -0
  36. package/dist/emulator-surface-ws.js +197 -0
  37. package/dist/machine-session-service.d.ts +182 -0
  38. package/dist/machine-session-service.js +559 -0
  39. package/dist/provider-cli-host.d.ts +10 -0
  40. package/dist/provider-cli-host.js +1 -0
  41. package/dist/remote-runtime-dispatcher.d.ts +94 -0
  42. package/dist/remote-runtime-dispatcher.js +380 -0
  43. package/dist/remote-runtime-session-projection.d.ts +19 -0
  44. package/dist/remote-runtime-session-projection.js +556 -0
  45. package/dist/remote-runtime.d.ts +22 -0
  46. package/dist/remote-runtime.js +32 -0
  47. package/dist/runtime-web-auth.d.ts +21 -0
  48. package/dist/runtime-web-auth.js +92 -0
  49. package/dist/server.d.ts +292 -10
  50. package/dist/server.js +314 -98
  51. package/dist/session-browser-service.d.ts +218 -0
  52. package/dist/session-browser-service.js +686 -0
  53. package/dist/session-browser-surface-coordinator.d.ts +23 -0
  54. package/dist/session-browser-surface-coordinator.js +563 -0
  55. package/dist/session-emulator-service.d.ts +167 -0
  56. package/dist/session-emulator-service.js +464 -0
  57. package/dist/session-runtime-index.d.ts +17 -0
  58. package/dist/session-runtime-index.js +86 -0
  59. package/dist/session-terminal-host.d.ts +46 -0
  60. package/dist/session-terminal-host.js +252 -0
  61. package/dist/terminal-ws.d.ts +9 -20
  62. package/dist/terminal-ws.js +420 -97
  63. package/package.json +9 -7
@@ -1,123 +1,446 @@
1
- import { WebSocketServer } from "ws";
2
- const TERMINAL_PATH = /^\/api\/sessions\/([^/]+)\/terminal$/;
3
- const MAX_DIM = 1000;
4
- // PTY-output WS coalescing, ported from omnigent's `_forward_pty_to_ws`
5
- // (ws_bridge.py): merge PTY chunks that arrive in the same tick into bounded
6
- // frames so a screen redraw is a few frames, not dozens. Cap each frame at
7
- // 64 KiB normally; drop to 2 KiB within 750 ms of user input so a post-keystroke
8
- // redraw stays in the browser terminal's synchronous-echo fast path (xterm falls
9
- // back to a laggy async write for frames over ~2 KiB). Oversize accumulations are
10
- // split across frames. Constants match omnigent's exactly.
11
- const WS_COALESCE_MAX_BYTES = 64 * 1024;
12
- const INTERACTIVE_WS_COALESCE_MAX_BYTES = 2048;
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
- // Application WebSocket close codes, matching omnigent (ws_bridge.py). 4404 = the
15
- // pane's inner process exited / session gone; 4500 = bridge could not open the
16
- // terminal. The frontend's isUnexpectedTerminalClose auto-reconnects only on
17
- // transport codes (1006 etc.), so these 4xxx codes correctly land on the
18
- // dead-pane overlay instead of a reconnect loop. rynx's server-initiated close is
19
- // always "gone" (a client *detach* is client-initiated and never reaches here),
20
- // so omnigent's 4405 "detached" has no rynx counterpart.
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 terminal WS upgrade handler to a running http.Server. */
23
+ /** Attach the authenticated Runtime-scoped terminal bridge to an HTTP server. */
24
24
  export function attachTerminalWs(server, deps) {
25
- const wss = new WebSocketServer({ noServer: true });
26
- server.on("upgrade", (req, socket, head) => {
27
- let url;
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
+ });
43
+ if (authorization) {
44
+ rejectUpgrade(socket, authorization.status, authorization.error);
45
+ return;
46
+ }
28
47
  try {
29
- url = new URL(req.url ?? "", "http://localhost");
48
+ wss.handleUpgrade(request, socket, head, (ws) => {
49
+ void handleConnection(ws, url, route, deps);
50
+ });
30
51
  }
31
52
  catch {
32
- return;
53
+ rejectUpgrade(socket, 400, "invalid terminal WebSocket upgrade");
33
54
  }
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
55
  });
41
56
  return wss;
42
57
  }
43
- function clampDim(raw, fallback) {
44
- const n = raw ? Number.parseInt(raw, 10) : Number.NaN;
45
- return Number.isFinite(n) && n > 0 && n <= MAX_DIM ? n : fallback;
46
- }
47
- function handleConnection(ws, url, sessionId, deps) {
48
- const role = url.searchParams.get("role") === "read-only" ? "read-only" : "owner";
49
- const cols = clampDim(url.searchParams.get("cols"), 80);
50
- const rows = clampDim(url.searchParams.get("rows"), 24);
51
- const cwd = deps.resolveCwd?.(sessionId) ?? process.cwd();
52
- const term = deps.runnerManager.openTerminal(sessionId, {
53
- role,
54
- cwd,
55
- cols,
56
- rows,
57
- ...(deps.command ? { command: deps.command } : {}),
58
- ...(deps.args ? { args: deps.args } : {}),
59
- });
60
- const sendText = (obj) => {
61
- if (ws.readyState === ws.OPEN)
62
- ws.send(JSON.stringify(obj));
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`).
58
+ async function handleConnection(ws, url, route, deps) {
59
+ let role;
60
+ let cols;
61
+ let rows;
62
+ try {
63
+ role = queryRole(url.searchParams.get("role"));
64
+ cols = queryDimension(url.searchParams.get("cols"), 80, "cols");
65
+ rows = queryDimension(url.searchParams.get("rows"), 24, "rows");
66
+ }
67
+ catch (error) {
68
+ closeBrowser(ws, WS_CLOSE_BAD_REQUEST, "invalid terminal options", error instanceof Error ? error.message : "invalid terminal options");
69
+ return;
70
+ }
71
+ const controller = new AbortController();
72
+ const pendingInputs = [];
73
+ let pendingInputBytes = 0;
74
+ let lease;
75
+ let terminal;
76
+ let browserClosed = false;
77
+ let cleanupPromise;
66
78
  let lastClientInputAt = 0;
67
- // Chunks buffered within the current tick, flushed coalesced on the next.
68
- let pending = [];
69
- let flushScheduled = false;
70
- const flushOutput = () => {
71
- flushScheduled = false;
72
- if (ws.readyState !== ws.OPEN || pending.length === 0) {
73
- pending = [];
79
+ let liveInputTail = Promise.resolve();
80
+ let liveInputFrames = 0;
81
+ let liveInputBytes = 0;
82
+ let inputReady = false;
83
+ const cleanup = () => {
84
+ if (cleanupPromise)
85
+ return cleanupPromise;
86
+ browserClosed = true;
87
+ controller.abort(new Error("Browser terminal disconnected"));
88
+ pendingInputs.length = 0;
89
+ pendingInputBytes = 0;
90
+ const opened = terminal;
91
+ terminal = undefined;
92
+ const acquired = lease;
93
+ lease = undefined;
94
+ cleanupPromise = (async () => {
95
+ try {
96
+ await opened?.close();
97
+ }
98
+ finally {
99
+ acquired?.release();
100
+ }
101
+ })();
102
+ return cleanupPromise;
103
+ };
104
+ const keepAliveMs = deps.keepAliveMs ?? 25_000;
105
+ const keepalive = keepAliveMs > 0
106
+ ? setInterval(() => {
107
+ if (ws.readyState === WebSocket.OPEN)
108
+ ws.ping();
109
+ }, keepAliveMs)
110
+ : undefined;
111
+ keepalive?.unref?.();
112
+ ws.on("message", (data, isBinary) => {
113
+ if (browserClosed)
74
114
  return;
115
+ let input;
116
+ try {
117
+ input = parseBrowserInput(data, isBinary);
75
118
  }
76
- const merged = pending.length === 1 ? pending[0] : Buffer.concat(pending);
77
- pending = [];
78
- const limit = Date.now() - lastClientInputAt < INTERACTIVE_ECHO_WINDOW_MS
79
- ? INTERACTIVE_WS_COALESCE_MAX_BYTES
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)));
119
+ catch (error) {
120
+ closeBrowser(ws, WS_CLOSE_BAD_REQUEST, "invalid terminal input", error instanceof Error ? error.message : "invalid terminal input");
121
+ void cleanup();
122
+ return;
83
123
  }
84
- };
85
- term.onData((chunk) => {
86
- pending.push(Buffer.from(chunk, "utf8"));
87
- if (!flushScheduled) {
88
- flushScheduled = true;
89
- setImmediate(flushOutput);
124
+ if (input.type === "input")
125
+ lastClientInputAt = Date.now();
126
+ const bytes = input.type === "input" ? input.data.byteLength : 16;
127
+ const opened = terminal;
128
+ if (opened && inputReady) {
129
+ if (liveInputFrames >= MAX_PENDING_BROWSER_MESSAGES ||
130
+ liveInputBytes + bytes > MAX_PENDING_BROWSER_BYTES) {
131
+ closeBrowser(ws, WS_CLOSE_BACKPRESSURE, "terminal input queue full", "Terminal input exceeded its bounded live queue");
132
+ void cleanup();
133
+ return;
134
+ }
135
+ liveInputFrames += 1;
136
+ liveInputBytes += bytes;
137
+ const operation = liveInputTail.then(() => applyBrowserInput(opened, input));
138
+ liveInputTail = operation
139
+ .catch((error) => {
140
+ closeForError(ws, error);
141
+ void cleanup();
142
+ })
143
+ .finally(() => {
144
+ liveInputFrames -= 1;
145
+ liveInputBytes -= bytes;
146
+ });
147
+ return;
90
148
  }
149
+ if (pendingInputs.length >= MAX_PENDING_BROWSER_MESSAGES ||
150
+ pendingInputBytes + bytes > MAX_PENDING_BROWSER_BYTES) {
151
+ closeBrowser(ws, WS_CLOSE_BACKPRESSURE, "terminal input queue full", "Terminal input exceeded its bounded pre-open queue");
152
+ void cleanup();
153
+ return;
154
+ }
155
+ pendingInputs.push({ input, bytes });
156
+ pendingInputBytes += bytes;
91
157
  });
92
- term.onExit(() => {
93
- flushOutput(); // drain the pane's final bytes before the close frame
94
- sendText({ t: "exit" });
95
- ws.close(WS_CLOSE_TERMINAL_GONE, "terminal session ended");
158
+ ws.on("close", () => {
159
+ if (keepalive)
160
+ clearInterval(keepalive);
161
+ void cleanup();
96
162
  });
97
- term.ready.then((opened) => sendText({ t: "opened", role: opened.role }), (error) => {
98
- sendText({ t: "error", message: error instanceof Error ? error.message : String(error) });
99
- ws.close(WS_CLOSE_INTERNAL_ERROR, "terminal bridge error");
163
+ ws.on("error", () => {
164
+ if (keepalive)
165
+ clearInterval(keepalive);
166
+ void cleanup();
100
167
  });
101
- ws.on("message", (data, isBinary) => {
102
- if (isBinary) {
103
- lastClientInputAt = Date.now();
104
- term.write(data.toString("utf8"));
168
+ try {
169
+ let opening;
170
+ if (route.legacy) {
171
+ if (!deps.legacyLocalTerminalHost) {
172
+ throw new SessionTerminalOpenError("method_not_found", "Legacy local Session terminals are unavailable");
173
+ }
174
+ opening = deps.legacyLocalTerminalHost.open(route.sessionId, {
175
+ role,
176
+ cols,
177
+ rows,
178
+ signal: controller.signal,
179
+ });
180
+ }
181
+ else {
182
+ if (!deps.runtimeConnectionResolver) {
183
+ throw new SessionTerminalOpenError("method_not_found", "Runtime-scoped Session terminals are unavailable");
184
+ }
185
+ const acquired = await deps.runtimeConnectionResolver.acquire(route.selector, {
186
+ signal: controller.signal,
187
+ });
188
+ if (browserClosed || controller.signal.aborted) {
189
+ acquired.release();
190
+ return;
191
+ }
192
+ lease = acquired;
193
+ opening = acquired.openSessionTerminal(route.sessionId, {
194
+ role,
195
+ cols,
196
+ rows,
197
+ signal: controller.signal,
198
+ timeoutMs: 10_000,
199
+ });
200
+ }
201
+ const opened = await opening;
202
+ if (browserClosed) {
203
+ await opened.close().catch(() => undefined);
105
204
  return;
106
205
  }
107
- try {
108
- const msg = JSON.parse(data.toString("utf8"));
109
- if (msg.t === "resize" && typeof msg.cols === "number" && typeof msg.rows === "number") {
110
- term.resize(msg.cols, msg.rows);
206
+ terminal = opened;
207
+ sendText(ws, { t: "opened", role: opened.role });
208
+ while (pendingInputs.length > 0) {
209
+ const batch = pendingInputs.splice(0);
210
+ for (const queued of batch) {
211
+ pendingInputBytes -= queued.bytes;
212
+ await applyBrowserInput(opened, queued.input);
111
213
  }
112
- else if (msg.t === "input" && typeof msg.data === "string") {
113
- lastClientInputAt = Date.now();
114
- term.write(msg.data);
214
+ }
215
+ pendingInputBytes = 0;
216
+ inputReady = true;
217
+ for await (const output of opened) {
218
+ if (browserClosed)
219
+ return;
220
+ const chunkSize = Date.now() - lastClientInputAt < INTERACTIVE_ECHO_WINDOW_MS
221
+ ? INTERACTIVE_OUTPUT_CHUNK_BYTES
222
+ : NORMAL_OUTPUT_CHUNK_BYTES;
223
+ for (let offset = 0; offset < output.byteLength; offset += chunkSize) {
224
+ await sendBinary(ws, output.subarray(offset, Math.min(offset + chunkSize, output.byteLength)));
115
225
  }
116
226
  }
117
- catch {
118
- // Ignore malformed control frames.
227
+ if (browserClosed)
228
+ return;
229
+ const closed = await opened.closed;
230
+ closeForTerminalEnd(ws, closed.reason);
231
+ }
232
+ catch (error) {
233
+ if (!browserClosed)
234
+ closeForError(ws, error);
235
+ }
236
+ finally {
237
+ if (keepalive)
238
+ clearInterval(keepalive);
239
+ await cleanup().catch(() => undefined);
240
+ }
241
+ }
242
+ function parseTerminalUpgrade(request) {
243
+ let url;
244
+ try {
245
+ url = new URL(request.url ?? "", "http://localhost");
246
+ }
247
+ catch {
248
+ return undefined;
249
+ }
250
+ const runtimeMatch = RUNTIME_TERMINAL_PATH.exec(url.pathname);
251
+ const legacyMatch = LEGACY_TERMINAL_PATH.exec(url.pathname);
252
+ if (!runtimeMatch && !legacyMatch)
253
+ return undefined;
254
+ try {
255
+ if (runtimeMatch) {
256
+ const selector = decodeURIComponent(runtimeMatch[1]);
257
+ const sessionId = decodeURIComponent(runtimeMatch[2]);
258
+ if (!SELECTOR_PATTERN.test(selector) || !validSessionId(sessionId)) {
259
+ return { failure: "invalid terminal route" };
260
+ }
261
+ return { url, route: { selector, sessionId, legacy: false } };
119
262
  }
263
+ const sessionId = decodeURIComponent(legacyMatch[1]);
264
+ if (!validSessionId(sessionId))
265
+ return { failure: "invalid terminal route" };
266
+ return { url, route: { selector: "local", sessionId, legacy: true } };
267
+ }
268
+ catch {
269
+ return { failure: "invalid terminal route encoding" };
270
+ }
271
+ }
272
+ function parseBrowserInput(data, isBinary) {
273
+ const bytes = rawDataBytes(data);
274
+ if (bytes.byteLength === 0 || bytes.byteLength > MAX_BROWSER_MESSAGE_BYTES) {
275
+ throw new Error("Terminal input frame size is invalid");
276
+ }
277
+ if (isBinary)
278
+ return { type: "input", data: bytes };
279
+ const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
280
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
281
+ throw new Error("Terminal control frame must be an object");
282
+ }
283
+ const frame = value;
284
+ if (frame.t === "resize" &&
285
+ Object.keys(frame).every((key) => key === "t" || key === "cols" || key === "rows") &&
286
+ validDimension(frame.cols) &&
287
+ validDimension(frame.rows)) {
288
+ return { type: "resize", cols: frame.cols, rows: frame.rows };
289
+ }
290
+ if (frame.t === "input" &&
291
+ Object.keys(frame).every((key) => key === "t" || key === "data") &&
292
+ typeof frame.data === "string" &&
293
+ frame.data.length > 0 &&
294
+ Buffer.byteLength(frame.data, "utf8") <= MAX_BROWSER_MESSAGE_BYTES) {
295
+ return { type: "input", data: Uint8Array.from(Buffer.from(frame.data, "utf8")) };
296
+ }
297
+ throw new Error("Unknown or invalid terminal control frame");
298
+ }
299
+ async function applyBrowserInput(terminal, input) {
300
+ if (input.type === "input") {
301
+ // Defense in depth: tmux -r also blocks writes, but bytes never reach it.
302
+ if (terminal.role === "owner")
303
+ await terminal.write(input.data);
304
+ return;
305
+ }
306
+ await terminal.resize(input.cols, input.rows);
307
+ }
308
+ async function sendBinary(ws, data) {
309
+ if (ws.readyState !== WebSocket.OPEN)
310
+ throw transportError("Browser terminal is closed");
311
+ if (ws.bufferedAmount + data.byteLength > MAX_BROWSER_BUFFERED_BYTES) {
312
+ const error = new Error("Browser terminal output exceeded its bounded buffer");
313
+ error.name = "BackpressureError";
314
+ throw error;
315
+ }
316
+ await new Promise((resolve, reject) => {
317
+ ws.send(data, { binary: true, compress: false }, (error) => {
318
+ if (error)
319
+ reject(transportError(error.message));
320
+ else
321
+ resolve();
322
+ });
120
323
  });
121
- ws.on("close", () => term.close());
122
- ws.on("error", () => term.close());
324
+ }
325
+ function sendText(ws, value) {
326
+ if (ws.readyState === WebSocket.OPEN) {
327
+ ws.send(JSON.stringify(value), { binary: false, compress: false });
328
+ }
329
+ }
330
+ function closeForTerminalEnd(ws, reason) {
331
+ if (reason === "client_closed")
332
+ return;
333
+ if (reason === "terminal_exited") {
334
+ closeBrowser(ws, WS_CLOSE_TERMINAL_EXITED, "terminal exited");
335
+ return;
336
+ }
337
+ if (reason === "backpressure" || reason === "capacity") {
338
+ closeBrowser(ws, WS_CLOSE_BACKPRESSURE, "terminal backpressure");
339
+ return;
340
+ }
341
+ if (reason === "unauthorized") {
342
+ closeBrowser(ws, WS_CLOSE_UNAUTHORIZED, "terminal authorization revoked");
343
+ return;
344
+ }
345
+ closeBrowser(ws, WS_CLOSE_INTERNAL_ERROR, "terminal bridge error");
346
+ }
347
+ function closeForError(ws, error) {
348
+ const code = errorCode(error);
349
+ const message = error instanceof Error ? error.message : "Terminal bridge failed";
350
+ if (code === "not_live") {
351
+ closeBrowser(ws, WS_CLOSE_TERMINAL_NOT_LIVE, "terminal not live");
352
+ }
353
+ else if (code === "unauthorized" || code === "authentication_failed") {
354
+ closeBrowser(ws, WS_CLOSE_UNAUTHORIZED, "terminal authorization failed");
355
+ }
356
+ else if (code === "method_not_found" || code === "incompatible" || code === "not_found") {
357
+ closeBrowser(ws, WS_CLOSE_RUNTIME_UNAVAILABLE, "terminal runtime unavailable");
358
+ }
359
+ else if (code === "capacity" || errorName(error) === "BackpressureError") {
360
+ closeBrowser(ws, WS_CLOSE_BACKPRESSURE, "terminal backpressure");
361
+ }
362
+ else if (code === "unreachable" ||
363
+ code === "closed" ||
364
+ code === "deadline_exceeded" ||
365
+ errorName(error) === "AbortError" ||
366
+ errorName(error) === "TransportError") {
367
+ closeBrowser(ws, 1012, "terminal transport unavailable");
368
+ }
369
+ else {
370
+ sendText(ws, { t: "error", message });
371
+ closeBrowser(ws, WS_CLOSE_INTERNAL_ERROR, "terminal bridge error");
372
+ }
373
+ }
374
+ function closeBrowser(ws, code, reason, message) {
375
+ if (message)
376
+ sendText(ws, { t: "error", message });
377
+ if (ws.readyState === WebSocket.OPEN)
378
+ ws.close(code, boundedCloseReason(reason));
379
+ }
380
+ function errorCode(error) {
381
+ return error && typeof error === "object" && "code" in error ? error.code : undefined;
382
+ }
383
+ function errorName(error) {
384
+ return error && typeof error === "object" && "name" in error ? error.name : undefined;
385
+ }
386
+ function transportError(message) {
387
+ const error = new Error(message);
388
+ error.name = "TransportError";
389
+ return error;
390
+ }
391
+ function queryDimension(raw, fallback, field) {
392
+ if (raw === null)
393
+ return fallback;
394
+ const value = Number(raw);
395
+ if (!validDimension(value)) {
396
+ throw new Error(`Terminal ${field} must be an integer within 1..${MAX_DIMENSION}`);
397
+ }
398
+ return value;
399
+ }
400
+ function queryRole(raw) {
401
+ if (raw === null || raw === "owner")
402
+ return "owner";
403
+ if (raw === "read-only")
404
+ return "read-only";
405
+ throw new Error("Terminal role must be owner or read-only");
406
+ }
407
+ function validDimension(value) {
408
+ return Number.isSafeInteger(value) && value >= 1 && value <= MAX_DIMENSION;
409
+ }
410
+ function validSessionId(value) {
411
+ return value.length > 0 && value.length <= 256 && !/[\u0000-\u001f\u007f]/.test(value);
412
+ }
413
+ function rawDataBytes(data) {
414
+ if (Buffer.isBuffer(data))
415
+ return Uint8Array.from(data);
416
+ if (data instanceof ArrayBuffer)
417
+ return new Uint8Array(data.slice(0));
418
+ if (Array.isArray(data))
419
+ return Uint8Array.from(Buffer.concat(data));
420
+ return Uint8Array.from(Buffer.from(data));
421
+ }
422
+ function rejectUpgrade(socket, status, message) {
423
+ if (socket.destroyed)
424
+ return;
425
+ const body = `${message}\n`;
426
+ socket.end(`HTTP/1.1 ${status} ${status === 401 ? "Unauthorized" : status === 403 ? "Forbidden" : "Bad Request"}\r\n` +
427
+ "Connection: close\r\n" +
428
+ "Content-Type: text/plain; charset=utf-8\r\n" +
429
+ `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n` +
430
+ "Cache-Control: no-store\r\n\r\n" +
431
+ body);
432
+ }
433
+ function boundedCloseReason(reason) {
434
+ if (Buffer.byteLength(reason, "utf8") <= 123)
435
+ return reason;
436
+ let bounded = "";
437
+ let bytes = 0;
438
+ for (const character of reason) {
439
+ const characterBytes = Buffer.byteLength(character, "utf8");
440
+ if (bytes + characterBytes > 123)
441
+ break;
442
+ bounded += character;
443
+ bytes += characterBytes;
444
+ }
445
+ return bounded;
123
446
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynx-ai/server",
3
- "version": "0.1.0",
3
+ "version": "0.1.9",
4
4
  "description": "Composition root: wires the @rynx-ai/runtime host to channel plugins (injected by the daemon) and exposes a liveness HTTP probe.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -22,16 +22,18 @@
22
22
  "@koa/router": "^15.4.0",
23
23
  "koa": "^3.2.0",
24
24
  "ws": "^8.21.0",
25
- "@rynx-ai/control-web": "0.1.0",
26
- "@rynx-ai/runtime": "0.1.0",
27
- "@rynx-ai/core": "0.1.0",
28
- "@rynx-ai/protocol": "0.1.0"
25
+ "@rynx-ai/core": "0.1.9",
26
+ "@rynx-ai/plugin-sdk": "0.1.9",
27
+ "@rynx-ai/remote-runtime-e2ee": "0.1.9",
28
+ "@rynx-ai/runtime": "0.1.9",
29
+ "@rynx-ai/protocol": "0.1.9"
29
30
  },
30
31
  "devDependencies": {
31
- "@types/ws": "^8.18.1"
32
+ "@types/ws": "^8.18.1",
33
+ "@rynx-ai/control-web": "0.1.9"
32
34
  },
33
35
  "scripts": {
34
- "build": "rm -rf dist && tsc -p tsconfig.json",
36
+ "build": "rm -rf dist && tsc -p tsconfig.json && mkdir -p dist/control-web && cp -R ../control-web/dist/. dist/control-web/",
35
37
  "start": "node dist/server.js",
36
38
  "dev": "tsx watch --tsconfig ../../tsconfig.json src/server.ts"
37
39
  }