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.
- package/CHANGELOG.md +72 -0
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/bin/ec-ts.js +18 -0
- package/dist/buffer/disk-queue.d.ts +140 -0
- package/dist/buffer/disk-queue.js +370 -0
- package/dist/cli/devices.d.ts +1 -0
- package/dist/cli/devices.js +61 -0
- package/dist/cli/enroll.d.ts +2 -0
- package/dist/cli/enroll.js +89 -0
- package/dist/cli/index.d.ts +10 -0
- package/dist/cli/index.js +116 -0
- package/dist/cli/messages.d.ts +1 -0
- package/dist/cli/messages.js +59 -0
- package/dist/cli/run.d.ts +5 -0
- package/dist/cli/run.js +112 -0
- package/dist/cli/status.d.ts +1 -0
- package/dist/cli/status.js +56 -0
- package/dist/cli/whoami.d.ts +2 -0
- package/dist/cli/whoami.js +41 -0
- package/dist/config/cmd-gate.d.ts +65 -0
- package/dist/config/cmd-gate.js +128 -0
- package/dist/config/settings.d.ts +209 -0
- package/dist/config/settings.js +627 -0
- package/dist/crypto/aes-gcm.d.ts +38 -0
- package/dist/crypto/aes-gcm.js +90 -0
- package/dist/crypto/hmac.d.ts +31 -0
- package/dist/crypto/hmac.js +52 -0
- package/dist/crypto/tls-guard.d.ts +36 -0
- package/dist/crypto/tls-guard.js +54 -0
- package/dist/daemon/manager.d.ts +82 -0
- package/dist/daemon/manager.js +461 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +63 -0
- package/dist/logging/file-logger.d.ts +21 -0
- package/dist/logging/file-logger.js +71 -0
- package/dist/network/ws-client.d.ts +221 -0
- package/dist/network/ws-client.js +1134 -0
- package/dist/session/fail-fast.d.ts +70 -0
- package/dist/session/fail-fast.js +122 -0
- package/dist/session/manager.d.ts +136 -0
- package/dist/session/manager.js +291 -0
- package/dist/session/persistence.d.ts +103 -0
- package/dist/session/persistence.js +194 -0
- package/dist/session/pi-rpc.d.ts +164 -0
- package/dist/session/pi-rpc.js +412 -0
- package/dist/session/sftp.d.ts +64 -0
- package/dist/session/sftp.js +335 -0
- package/dist/session/shell-frame.d.ts +77 -0
- package/dist/session/shell-frame.js +199 -0
- package/dist/session/shell.d.ts +124 -0
- package/dist/session/shell.js +300 -0
- package/docs/CONFIGURATION.md +169 -0
- package/docs/INSTALLATION.md +164 -0
- package/docs/PROTOCOL.md +248 -0
- package/docs/TROUBLESHOOTING.md +177 -0
- 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;
|