edge-ai-client-ts 1.0.0

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 (57) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/LICENSE +21 -0
  3. package/README.md +174 -0
  4. package/bin/ec-ts.js +18 -0
  5. package/dist/buffer/disk-queue.d.ts +140 -0
  6. package/dist/buffer/disk-queue.js +370 -0
  7. package/dist/cli/devices.d.ts +1 -0
  8. package/dist/cli/devices.js +61 -0
  9. package/dist/cli/enroll.d.ts +2 -0
  10. package/dist/cli/enroll.js +89 -0
  11. package/dist/cli/index.d.ts +10 -0
  12. package/dist/cli/index.js +116 -0
  13. package/dist/cli/messages.d.ts +1 -0
  14. package/dist/cli/messages.js +59 -0
  15. package/dist/cli/run.d.ts +5 -0
  16. package/dist/cli/run.js +112 -0
  17. package/dist/cli/status.d.ts +1 -0
  18. package/dist/cli/status.js +56 -0
  19. package/dist/cli/whoami.d.ts +2 -0
  20. package/dist/cli/whoami.js +41 -0
  21. package/dist/config/cmd-gate.d.ts +65 -0
  22. package/dist/config/cmd-gate.js +128 -0
  23. package/dist/config/settings.d.ts +209 -0
  24. package/dist/config/settings.js +627 -0
  25. package/dist/crypto/aes-gcm.d.ts +38 -0
  26. package/dist/crypto/aes-gcm.js +90 -0
  27. package/dist/crypto/hmac.d.ts +31 -0
  28. package/dist/crypto/hmac.js +52 -0
  29. package/dist/crypto/tls-guard.d.ts +36 -0
  30. package/dist/crypto/tls-guard.js +54 -0
  31. package/dist/daemon/manager.d.ts +82 -0
  32. package/dist/daemon/manager.js +461 -0
  33. package/dist/index.d.ts +39 -0
  34. package/dist/index.js +63 -0
  35. package/dist/logging/file-logger.d.ts +21 -0
  36. package/dist/logging/file-logger.js +71 -0
  37. package/dist/network/ws-client.d.ts +221 -0
  38. package/dist/network/ws-client.js +1134 -0
  39. package/dist/session/fail-fast.d.ts +70 -0
  40. package/dist/session/fail-fast.js +122 -0
  41. package/dist/session/manager.d.ts +136 -0
  42. package/dist/session/manager.js +291 -0
  43. package/dist/session/persistence.d.ts +103 -0
  44. package/dist/session/persistence.js +194 -0
  45. package/dist/session/pi-rpc.d.ts +164 -0
  46. package/dist/session/pi-rpc.js +412 -0
  47. package/dist/session/sftp.d.ts +64 -0
  48. package/dist/session/sftp.js +335 -0
  49. package/dist/session/shell-frame.d.ts +77 -0
  50. package/dist/session/shell-frame.js +199 -0
  51. package/dist/session/shell.d.ts +124 -0
  52. package/dist/session/shell.js +300 -0
  53. package/docs/CONFIGURATION.md +169 -0
  54. package/docs/INSTALLATION.md +164 -0
  55. package/docs/PROTOCOL.md +248 -0
  56. package/docs/TROUBLESHOOTING.md +177 -0
  57. package/package.json +79 -0
@@ -0,0 +1,335 @@
1
+ /**
2
+ * SFTP-over-frame — structured file RPC over the shell binary channel.
3
+ * Mirrors `edge-client/src/session/sftp.rs`.
4
+ *
5
+ * A frame type `FrameType::Sftp = 6` carries a JSON request/response payload
6
+ * on the existing 17-byte shell frame. The relay is a dumb byte-pump:
7
+ * it forwards Sftp frames verbatim; ALL filesystem logic lives here.
8
+ * Under the hood this is JSON RPC over `node:fs` — we do NOT shell out
9
+ * to `sftp` (the edge-client already runs as the Standard User with full
10
+ * filesystem access).
11
+ *
12
+ * ## Protocol
13
+ * Request (browser → edge, Sftp frame payload = UTF-8 JSON):
14
+ * ```json
15
+ * {"op":"list|read|write|delete|stat","path":"/abs/path"[,"content":"..."],"reqId":1}
16
+ * ```
17
+ * Response (edge → browser):
18
+ * ```json
19
+ * {"reqId":1,"ok":true,"data":{...}}
20
+ * {"reqId":1,"ok":false,"error":"..."}
21
+ * ```
22
+ *
23
+ * ## P1-a / R-MED-2 — per-session SFTP chroot
24
+ * When the relay sends an `SftpRoot` control frame (frame type 7) on shell
25
+ * session open, the edge stores the supplied root path in a per-session map.
26
+ * Every subsequent Sftp op for that session is resolved under the root.
27
+ * A `null` root (or absent) means "no chroot" (default).
28
+ *
29
+ * ## E-H2 — max file size for read/write ops
30
+ * Files larger than 32 MiB are refused to prevent browser OOM.
31
+ */
32
+ import * as fs from 'node:fs';
33
+ import * as path from 'node:path';
34
+ import { EventEmitter } from 'node:events';
35
+ // ---------------------------------------------------------------------------
36
+ // Constants
37
+ // ---------------------------------------------------------------------------
38
+ /** Maximum file size for read/write ops (32 MiB). Mirrors Rust MAX_SFTP_BYTES. */
39
+ const MAX_SFTP_BYTES = 32 * 1024 * 1024;
40
+ function okResponse(reqId, data) {
41
+ return { reqId, ok: true, data };
42
+ }
43
+ function errResponse(reqId, error) {
44
+ return { reqId, ok: false, error };
45
+ }
46
+ /** Serialize response to JSON bytes. Cannot fail (mirrors Rust SftpResponse::to_bytes). */
47
+ function responseToBytes(resp) {
48
+ try {
49
+ return Buffer.from(JSON.stringify(resp), 'utf8');
50
+ }
51
+ catch {
52
+ return Buffer.from('{"reqId":-1,"ok":false,"error":"serialize failed"}', 'utf8');
53
+ }
54
+ }
55
+ // ---------------------------------------------------------------------------
56
+ // Per-session chroot map (mirrors Rust SESSION_ROOTS)
57
+ // ---------------------------------------------------------------------------
58
+ const sessionRoots = new Map();
59
+ function setSessionRoot(sessionId, payload) {
60
+ let msg;
61
+ try {
62
+ msg = JSON.parse(payload.toString('utf8'));
63
+ }
64
+ catch {
65
+ // Malformed payload — ignored (mirrors Rust behavior)
66
+ return;
67
+ }
68
+ const root = msg.root;
69
+ if (root === undefined || root === null || root === '') {
70
+ sessionRoots.delete(sessionId);
71
+ return;
72
+ }
73
+ // Validation: absolute, no null bytes, length <= 4096
74
+ if (!root.startsWith('/'))
75
+ return;
76
+ if (root.includes('\0'))
77
+ return;
78
+ if (root.length > 4096)
79
+ return;
80
+ if (root === '/') {
81
+ sessionRoots.delete(sessionId);
82
+ return;
83
+ }
84
+ sessionRoots.set(sessionId, root);
85
+ }
86
+ // ---------------------------------------------------------------------------
87
+ // Path resolution (mirrors Rust resolve_sftp_path)
88
+ // ---------------------------------------------------------------------------
89
+ function resolveSftpPath(sessionId, userPath) {
90
+ const root = sessionRoots.get(sessionId);
91
+ // No chroot → pass through
92
+ if (root === undefined) {
93
+ if (userPath.includes('\0')) {
94
+ throw new Error('path contains null byte');
95
+ }
96
+ return userPath;
97
+ }
98
+ // Chroot active — reject absolute paths and traversal
99
+ if (userPath.includes('\0')) {
100
+ throw new Error('path contains null byte');
101
+ }
102
+ // Empty path = root itself
103
+ if (userPath === '' || userPath === '.') {
104
+ return root;
105
+ }
106
+ // Reject absolute paths
107
+ if (path.isAbsolute(userPath)) {
108
+ throw new Error('absolute path not allowed under chroot');
109
+ }
110
+ // Reject .. traversal
111
+ const parts = userPath.split(path.sep).filter((p) => p !== '' && p !== '.');
112
+ for (const part of parts) {
113
+ if (part === '..') {
114
+ throw new Error('path traversal not allowed (..)');
115
+ }
116
+ }
117
+ // Concatenate root + user_path
118
+ const resolved = path.join(root, userPath);
119
+ // Containment check: resolved path must start with root
120
+ const resolvedAbs = path.resolve(resolved);
121
+ const rootAbs = path.resolve(root);
122
+ if (!resolvedAbs.startsWith(rootAbs + path.sep) && resolvedAbs !== rootAbs) {
123
+ throw new Error(`path escapes chroot root (resolved outside ${rootAbs})`);
124
+ }
125
+ return resolved;
126
+ }
127
+ // ---------------------------------------------------------------------------
128
+ // File operations (mirrors Rust op_list, op_read, etc.)
129
+ // ---------------------------------------------------------------------------
130
+ function opList(reqPath, reqId) {
131
+ let entries = [];
132
+ try {
133
+ const dirEntries = fs.readdirSync(reqPath, { withFileTypes: true });
134
+ for (const entry of dirEntries) {
135
+ let size = 0;
136
+ let modified = 0;
137
+ try {
138
+ const stat = fs.statSync(path.join(reqPath, entry.name));
139
+ size = stat.isDirectory() ? 0 : stat.size;
140
+ modified = Math.floor(stat.mtimeMs / 1000);
141
+ }
142
+ catch {
143
+ // skip entries we can't stat
144
+ continue;
145
+ }
146
+ entries.push({
147
+ name: entry.name,
148
+ kind: entry.isDirectory() ? 'dir' : 'file',
149
+ size,
150
+ modified,
151
+ });
152
+ }
153
+ // Sort: dirs first, then name ascending
154
+ entries.sort((a, b) => {
155
+ if (a.kind !== b.kind)
156
+ return a.kind === 'dir' ? -1 : 1;
157
+ return a.name.localeCompare(b.name);
158
+ });
159
+ }
160
+ catch (e) {
161
+ return errResponse(reqId, `list failed: ${String(e)}`);
162
+ }
163
+ return okResponse(reqId, { entries });
164
+ }
165
+ function opRead(reqPath, reqId) {
166
+ // Size check first
167
+ try {
168
+ const stat = fs.statSync(reqPath);
169
+ if (stat.size > MAX_SFTP_BYTES) {
170
+ return errResponse(reqId, `file too large: ${stat.size} bytes (max ${MAX_SFTP_BYTES} bytes / ${MAX_SFTP_BYTES / (1024 * 1024)} MiB)`);
171
+ }
172
+ }
173
+ catch {
174
+ // stat failed — proceed to read (will surface the I/O error)
175
+ }
176
+ try {
177
+ const content = fs.readFileSync(reqPath, 'utf8');
178
+ return okResponse(reqId, { content });
179
+ }
180
+ catch (e) {
181
+ const err = e;
182
+ if (err.code === 'EEXIST' || (e instanceof Error && e.message.includes('invalid'))) {
183
+ return errResponse(reqId, 'not UTF-8 / binary');
184
+ }
185
+ return errResponse(reqId, `read failed: ${String(e)}`);
186
+ }
187
+ }
188
+ function opWrite(reqPath, content, reqId) {
189
+ if (content === undefined) {
190
+ return errResponse(reqId, "write requires 'content'");
191
+ }
192
+ // Size cap
193
+ if (Buffer.byteLength(content, 'utf8') > MAX_SFTP_BYTES) {
194
+ return errResponse(reqId, `content too large: ${Buffer.byteLength(content, 'utf8')} bytes (max ${MAX_SFTP_BYTES} bytes / ${MAX_SFTP_BYTES / (1024 * 1024)} MiB)`);
195
+ }
196
+ // Check parent exists
197
+ const parent = path.dirname(reqPath);
198
+ if (parent !== '' && parent !== '.' && !fs.existsSync(parent)) {
199
+ return errResponse(reqId, 'parent does not exist');
200
+ }
201
+ const bytes = Buffer.byteLength(content, 'utf8');
202
+ try {
203
+ fs.writeFileSync(reqPath, content, 'utf8');
204
+ return okResponse(reqId, { bytes });
205
+ }
206
+ catch (e) {
207
+ return errResponse(reqId, `write failed: ${String(e)}`);
208
+ }
209
+ }
210
+ function opDelete(reqPath, reqId) {
211
+ let stat;
212
+ try {
213
+ stat = fs.lstatSync(reqPath);
214
+ }
215
+ catch (e) {
216
+ return errResponse(reqId, `delete failed: ${String(e)}`);
217
+ }
218
+ if (stat.isDirectory()) {
219
+ // Check if empty
220
+ try {
221
+ const children = fs.readdirSync(reqPath);
222
+ if (children.length > 0) {
223
+ return errResponse(reqId, 'directory not empty');
224
+ }
225
+ }
226
+ catch (e) {
227
+ return errResponse(reqId, `delete failed: ${String(e)}`);
228
+ }
229
+ try {
230
+ fs.rmdirSync(reqPath);
231
+ return okResponse(reqId, { deleted: true });
232
+ }
233
+ catch (e) {
234
+ return errResponse(reqId, `delete failed: ${String(e)}`);
235
+ }
236
+ }
237
+ else {
238
+ try {
239
+ fs.unlinkSync(reqPath);
240
+ return okResponse(reqId, { deleted: true });
241
+ }
242
+ catch (e) {
243
+ return errResponse(reqId, `delete failed: ${String(e)}`);
244
+ }
245
+ }
246
+ }
247
+ function opStat(reqPath, reqId) {
248
+ let stat;
249
+ try {
250
+ stat = fs.statSync(reqPath);
251
+ }
252
+ catch (e) {
253
+ return errResponse(reqId, `stat failed: ${String(e)}`);
254
+ }
255
+ const isDir = stat.isDirectory();
256
+ return okResponse(reqId, {
257
+ kind: isDir ? 'dir' : 'file',
258
+ size: isDir ? 0 : stat.size,
259
+ modified: Math.floor(stat.mtimeMs / 1000),
260
+ });
261
+ }
262
+ function dispatch(sessionId, req) {
263
+ let resolvedPath;
264
+ try {
265
+ resolvedPath = resolveSftpPath(sessionId, req.path);
266
+ }
267
+ catch (e) {
268
+ return errResponse(reqId(req), String(e));
269
+ }
270
+ switch (req.op) {
271
+ case 'list':
272
+ return opList(resolvedPath, reqId(req));
273
+ case 'read':
274
+ return opRead(resolvedPath, reqId(req));
275
+ case 'write':
276
+ return opWrite(resolvedPath, req.content, reqId(req));
277
+ case 'delete':
278
+ return opDelete(resolvedPath, reqId(req));
279
+ case 'stat':
280
+ return opStat(resolvedPath, reqId(req));
281
+ default:
282
+ return errResponse(reqId(req), `unknown op: ${req.op}`);
283
+ }
284
+ }
285
+ function reqId(req) {
286
+ return typeof req.reqId === 'number' ? req.reqId : -1;
287
+ }
288
+ export class SftpForwarder {
289
+ emitter = new EventEmitter();
290
+ /**
291
+ * Handle an inbound Sftp frame. Mirrors Rust `handle_sftp_request`.
292
+ * Parses the JSON request, dispatches the file op, and emits the response
293
+ * frame (to be sent back to the relay/browser).
294
+ */
295
+ handleFrame(sessionId, frameType, payload) {
296
+ // SftpRoot (frame type 7) → store the session root
297
+ if (frameType === 7) {
298
+ setSessionRoot(sessionId, payload);
299
+ return;
300
+ }
301
+ // Sftp (frame type 6) → dispatch the file op
302
+ const req = (() => {
303
+ try {
304
+ return JSON.parse(payload.toString('utf8'));
305
+ }
306
+ catch {
307
+ return { op: 'parse-error', path: '', reqId: -1 };
308
+ }
309
+ })();
310
+ if (req.op === 'parse-error') {
311
+ const resp = errResponse(-1, 'bad request');
312
+ const respBytes = responseToBytes(resp);
313
+ this.emitter.emit('frame', sessionId, frameType, respBytes);
314
+ return;
315
+ }
316
+ const resp = dispatch(sessionId, req);
317
+ const respBytes = responseToBytes(resp);
318
+ this.emitter.emit('frame', sessionId, frameType, respBytes);
319
+ }
320
+ /**
321
+ * Register a handler for outbound Sftp response frames.
322
+ * The handler receives (sessionId, frameType, payloadBytes).
323
+ */
324
+ onFrame(handler) {
325
+ this.emitter.on('frame', handler);
326
+ }
327
+ }
328
+ // ---------------------------------------------------------------------------
329
+ // Exports for testing
330
+ // ---------------------------------------------------------------------------
331
+ /** Clear all session roots (for tests only). */
332
+ export function clearSessionRoots() {
333
+ sessionRoots.clear();
334
+ }
335
+ export { setSessionRoot, resolveSftpPath, dispatch };
@@ -0,0 +1,77 @@
1
+ /** Header length: 16-byte session_id + 1-byte frame_type. */
2
+ export declare const SHELL_HEADER_LEN = 17;
3
+ /** 16-byte raw UUID length (matches Rust `Uuid::as_bytes()` length). */
4
+ export declare const SESSION_ID_LEN = 16;
5
+ /** Resize payload length: cols (u16 BE) + rows (u16 BE). */
6
+ export declare const RESIZE_PAYLOAD_LEN = 4;
7
+ /** Wire-numeric frame type. Values are part of the public protocol — DO NOT renumber. */
8
+ export declare const FrameType: {
9
+ readonly Data: 0;
10
+ readonly Resize: 1;
11
+ readonly Close: 2;
12
+ readonly OpenControl: 3;
13
+ readonly CloseControl: 4;
14
+ readonly Sftp: 6;
15
+ readonly SftpRoot: 7;
16
+ };
17
+ export type FrameTypeValue = (typeof FrameType)[keyof typeof FrameType];
18
+ /**
19
+ * Inverse lookup: byte → typed name. Returns `null` for unknown / reserved.
20
+ * Mirrors Rust `FrameType::from_u8` exactly.
21
+ */
22
+ export declare function frameTypeFromByte(b: number): FrameTypeValue | null;
23
+ /** Generate a fresh 16-byte session id (UUID v4 raw bytes). */
24
+ export declare function newSessionId(): Buffer;
25
+ /**
26
+ * Convert a UUID string ("550e8400-e29b-41d4-a716-446655440000" or
27
+ * already-stripped 32-hex form) to the 16-byte session id used on the wire.
28
+ * Returns null on invalid input. Mirrors Rust `Uuid::parse_str` then `as_bytes`.
29
+ */
30
+ export declare function sessionIdFromString(id: string): Buffer | null;
31
+ /** Convert a 16-byte session id back to canonical UUID string (with dashes). */
32
+ export declare function sessionIdToString(bytes: Uint8Array): string | null;
33
+ export interface ShellFrame {
34
+ /** 16-byte raw session id (NOT a hex string). */
35
+ readonly sessionId: Uint8Array;
36
+ readonly frameType: FrameTypeValue;
37
+ readonly payload: Uint8Array;
38
+ }
39
+ /**
40
+ * Build a `data` frame carrying raw PTY bytes (or browser keystrokes, the
41
+ * wire is symmetric). Mirrors Rust `ShellFrame::data`.
42
+ */
43
+ export declare function dataFrame(sessionId: Uint8Array, payload: Uint8Array): ShellFrame;
44
+ /**
45
+ * Build a `resize` frame. Payload is 4 big-endian bytes: cols u16 BE + rows u16 BE.
46
+ * Mirrors Rust `ShellFrame::resize`.
47
+ */
48
+ export declare function resizeFrame(sessionId: Uint8Array, cols: number, rows: number): ShellFrame;
49
+ /**
50
+ * Build a `close` frame (no payload). Mirrors Rust `ShellFrame::close`.
51
+ */
52
+ export declare function closeFrame(sessionId: Uint8Array): ShellFrame;
53
+ /**
54
+ * Encode a frame to its on-wire byte representation. Matches the Rust
55
+ * `ShellFrame::encode` exactly: `[16 session_id][1 frame_type][payload...]`.
56
+ */
57
+ export declare function encodeFrame(frame: ShellFrame): Buffer;
58
+ /**
59
+ * Decode the on-wire bytes into a frame. Returns null if the buffer is
60
+ * shorter than `SHELL_HEADER_LEN` (17 bytes) or the frame-type byte is
61
+ * unknown / reserved. Mirrors Rust `ShellFrame::decode`.
62
+ *
63
+ * The payload is COPIED (not aliased) so callers can safely reuse the input
64
+ * buffer for the next frame — mirrors the Rust comment about copying into
65
+ * the reader task buffer.
66
+ */
67
+ export declare function decodeFrame(bytes: Uint8Array): ShellFrame | null;
68
+ /**
69
+ * Decode a resize frame's payload into [cols, rows]. Returns null if the
70
+ * payload is not exactly 4 bytes (defensive — encoder always emits 4 bytes
71
+ * but a malformed peer might send something else).
72
+ */
73
+ export declare function decodeResizePayload(payload: Uint8Array): {
74
+ cols: number;
75
+ rows: number;
76
+ } | null;
77
+ export declare const __RESERVED_FRAME_TYPE_5__: 5;
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Shell frame codec — the on-wire binary protocol for `/shell-edge`.
3
+ *
4
+ * Mirrors `edge-client/src/session/shell.rs` BYTE-FOR-BYTE. A Rust
5
+ * edge-client and this TS client must produce identical bytes so they can
6
+ * talk to the same relay-server / browser endpoint interchangeably.
7
+ *
8
+ * Wire format (§2b of docs/SHELL_DESIGN.md):
9
+ *
10
+ * ┌─────────────────────────────────┬──────────────┬─────────────────────┐
11
+ * │ session_id (16 raw UUID bytes) │ frame_type │ payload │
12
+ * │ 0..15 │ byte 16 │ 17.. (raw bytes) │
13
+ * └─────────────────────────────────┴──────────────┴─────────────────────┘
14
+ *
15
+ * Resize frames encode the payload as 4 big-endian bytes: [cols u16 BE][rows u16 BE].
16
+ *
17
+ * Frame types (mirror Rust `FrameType` enum EXACTLY — do not renumber):
18
+ * Data = 0 Raw PTY bytes (edge→browser stdout OR browser→edge keystrokes).
19
+ * Resize = 1 Browser→edge request to resize the PTY (payload = cols,rows u16 BE).
20
+ * Close = 2 Browser→edge request to close the session (no payload).
21
+ * OpenControl = 3 Relay→edge: spawn the PTY (payload = JSON {cols,rows,shell?}).
22
+ * CloseControl = 4 Relay→edge: tear-down the PTY (no payload).
23
+ * Sftp = 6 SFTP frame (payload = UTF-8 JSON request OR raw SFTP bytes).
24
+ * SftpRoot = 7 Relay→edge: SFTP root directory announcement (payload = UTF-8 JSON).
25
+ *
26
+ * Frame types 5 is RESERVED (not used in either Rust or TS — must not collide
27
+ * with future wire assignments).
28
+ */
29
+ import { randomUUID } from 'node:crypto';
30
+ // ──────────────────────────── constants ────────────────────────────
31
+ /** Header length: 16-byte session_id + 1-byte frame_type. */
32
+ export const SHELL_HEADER_LEN = 17;
33
+ /** 16-byte raw UUID length (matches Rust `Uuid::as_bytes()` length). */
34
+ export const SESSION_ID_LEN = 16;
35
+ /** Resize payload length: cols (u16 BE) + rows (u16 BE). */
36
+ export const RESIZE_PAYLOAD_LEN = 4;
37
+ /** Reserved frame type (must not be assigned). */
38
+ const FRAME_TYPE_RESERVED = 5;
39
+ // ──────────────────────────── frame type ───────────────────────────
40
+ /** Wire-numeric frame type. Values are part of the public protocol — DO NOT renumber. */
41
+ export const FrameType = {
42
+ Data: 0,
43
+ Resize: 1,
44
+ Close: 2,
45
+ OpenControl: 3,
46
+ CloseControl: 4,
47
+ // 5 is RESERVED — never assigned (see FRAME_TYPE_RESERVED above).
48
+ Sftp: 6,
49
+ SftpRoot: 7,
50
+ };
51
+ /**
52
+ * Inverse lookup: byte → typed name. Returns `null` for unknown / reserved.
53
+ * Mirrors Rust `FrameType::from_u8` exactly.
54
+ */
55
+ export function frameTypeFromByte(b) {
56
+ switch (b) {
57
+ case FrameType.Data:
58
+ return FrameType.Data;
59
+ case FrameType.Resize:
60
+ return FrameType.Resize;
61
+ case FrameType.Close:
62
+ return FrameType.Close;
63
+ case FrameType.OpenControl:
64
+ return FrameType.OpenControl;
65
+ case FrameType.CloseControl:
66
+ return FrameType.CloseControl;
67
+ case FrameType.Sftp:
68
+ return FrameType.Sftp;
69
+ case FrameType.SftpRoot:
70
+ return FrameType.SftpRoot;
71
+ default:
72
+ // 5 and any other value (incl. reserved 5) → unknown.
73
+ return null;
74
+ }
75
+ }
76
+ // ──────────────────────────── session id ───────────────────────────
77
+ /** Generate a fresh 16-byte session id (UUID v4 raw bytes). */
78
+ export function newSessionId() {
79
+ // crypto.randomUUID() is RFC 4122 v4 — exactly the same shape as Rust Uuid::new_v4().
80
+ const u = randomUUID();
81
+ // Strip dashes → 32 hex chars → 16 raw bytes.
82
+ const hex = u.replace(/-/g, '');
83
+ return Buffer.from(hex, 'hex');
84
+ }
85
+ /**
86
+ * Convert a UUID string ("550e8400-e29b-41d4-a716-446655440000" or
87
+ * already-stripped 32-hex form) to the 16-byte session id used on the wire.
88
+ * Returns null on invalid input. Mirrors Rust `Uuid::parse_str` then `as_bytes`.
89
+ */
90
+ export function sessionIdFromString(id) {
91
+ if (typeof id !== 'string' || id.length === 0)
92
+ return null;
93
+ const stripped = id.includes('-') ? id.replace(/-/g, '') : id;
94
+ if (!/^[0-9a-fA-F]{32}$/.test(stripped))
95
+ return null;
96
+ try {
97
+ return Buffer.from(stripped, 'hex');
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
103
+ /** Convert a 16-byte session id back to canonical UUID string (with dashes). */
104
+ export function sessionIdToString(bytes) {
105
+ if (bytes.length !== SESSION_ID_LEN)
106
+ return null;
107
+ const hex = Buffer.from(bytes).toString('hex');
108
+ // 8-4-4-4-12 layout
109
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
110
+ }
111
+ /**
112
+ * Build a `data` frame carrying raw PTY bytes (or browser keystrokes, the
113
+ * wire is symmetric). Mirrors Rust `ShellFrame::data`.
114
+ */
115
+ export function dataFrame(sessionId, payload) {
116
+ assertSessionId(sessionId);
117
+ return { sessionId, frameType: FrameType.Data, payload };
118
+ }
119
+ /**
120
+ * Build a `resize` frame. Payload is 4 big-endian bytes: cols u16 BE + rows u16 BE.
121
+ * Mirrors Rust `ShellFrame::resize`.
122
+ */
123
+ export function resizeFrame(sessionId, cols, rows) {
124
+ assertSessionId(sessionId);
125
+ assertU16(cols, 'cols');
126
+ assertU16(rows, 'rows');
127
+ const payload = new Uint8Array(RESIZE_PAYLOAD_LEN);
128
+ // DataView writes big-endian explicitly (matches Rust u16::to_be_bytes).
129
+ new DataView(payload.buffer).setUint16(0, cols, false);
130
+ new DataView(payload.buffer).setUint16(2, rows, false);
131
+ return { sessionId, frameType: FrameType.Resize, payload };
132
+ }
133
+ /**
134
+ * Build a `close` frame (no payload). Mirrors Rust `ShellFrame::close`.
135
+ */
136
+ export function closeFrame(sessionId) {
137
+ assertSessionId(sessionId);
138
+ return { sessionId, frameType: FrameType.Close, payload: new Uint8Array(0) };
139
+ }
140
+ // ──────────────────────────── encode / decode ──────────────────────
141
+ /**
142
+ * Encode a frame to its on-wire byte representation. Matches the Rust
143
+ * `ShellFrame::encode` exactly: `[16 session_id][1 frame_type][payload...]`.
144
+ */
145
+ export function encodeFrame(frame) {
146
+ assertSessionId(frame.sessionId);
147
+ const buf = Buffer.allocUnsafe(SHELL_HEADER_LEN + frame.payload.length);
148
+ buf.set(frame.sessionId, 0);
149
+ buf[16] = frame.frameType;
150
+ buf.set(frame.payload, SHELL_HEADER_LEN);
151
+ return buf;
152
+ }
153
+ /**
154
+ * Decode the on-wire bytes into a frame. Returns null if the buffer is
155
+ * shorter than `SHELL_HEADER_LEN` (17 bytes) or the frame-type byte is
156
+ * unknown / reserved. Mirrors Rust `ShellFrame::decode`.
157
+ *
158
+ * The payload is COPIED (not aliased) so callers can safely reuse the input
159
+ * buffer for the next frame — mirrors the Rust comment about copying into
160
+ * the reader task buffer.
161
+ */
162
+ export function decodeFrame(bytes) {
163
+ if (bytes.length < SHELL_HEADER_LEN)
164
+ return null;
165
+ const ft = frameTypeFromByte(bytes[16]);
166
+ if (ft === null)
167
+ return null;
168
+ const sessionId = new Uint8Array(SESSION_ID_LEN);
169
+ sessionId.set(bytes.subarray(0, SESSION_ID_LEN), 0);
170
+ const payload = new Uint8Array(bytes.length - SHELL_HEADER_LEN);
171
+ payload.set(bytes.subarray(SHELL_HEADER_LEN), 0);
172
+ return { sessionId, frameType: ft, payload };
173
+ }
174
+ // ──────────────────────────── resize helpers ──────────────────────
175
+ /**
176
+ * Decode a resize frame's payload into [cols, rows]. Returns null if the
177
+ * payload is not exactly 4 bytes (defensive — encoder always emits 4 bytes
178
+ * but a malformed peer might send something else).
179
+ */
180
+ export function decodeResizePayload(payload) {
181
+ if (payload.length !== RESIZE_PAYLOAD_LEN)
182
+ return null;
183
+ const dv = new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
184
+ // false = big-endian (matches Rust u16::from_be_bytes).
185
+ return { cols: dv.getUint16(0, false), rows: dv.getUint16(2, false) };
186
+ }
187
+ // ──────────────────────────── guards (internal) ────────────────────
188
+ function assertSessionId(id) {
189
+ if (!(id instanceof Uint8Array) || id.length !== SESSION_ID_LEN) {
190
+ throw new RangeError(`sessionId must be a ${SESSION_ID_LEN}-byte Uint8Array (got ${id?.length ?? 'undefined'})`);
191
+ }
192
+ }
193
+ function assertU16(v, name) {
194
+ if (!Number.isInteger(v) || v < 0 || v > 0xffff) {
195
+ throw new RangeError(`${name} must be an integer in [0, 65535] (got ${v})`);
196
+ }
197
+ }
198
+ // Re-export the reserved sentinel so tests can assert it's not assignable.
199
+ export const __RESERVED_FRAME_TYPE_5__ = FRAME_TYPE_RESERVED;