@constructive-db/machine-protocol 0.2.2 → 0.3.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/esm/index.js +126 -2
- package/index.d.ts +96 -3
- package/index.js +128 -3
- package/package.json +2 -2
package/esm/index.js
CHANGED
|
@@ -236,6 +236,53 @@ export class LineSplitter {
|
|
|
236
236
|
function stripCarriageReturn(line) {
|
|
237
237
|
return line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
238
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* Reading paths under a root, read-only. Two vocabularies, like everything
|
|
241
|
+
* else: the runner leg (`path_list` / `path_read`) names a root relative to the
|
|
242
|
+
* runner's policy cwd and knows nothing about sessions; the client leg
|
|
243
|
+
* (`fs_list` / `fs_read`) names a session, and the relay maps it to that
|
|
244
|
+
* session's working tree after the tenant authorizes it. A request carries a
|
|
245
|
+
* `requestId` and is answered by exactly one frame with the same id: a
|
|
246
|
+
* listing, a content frame, or an `error` with a {@link PathErrorCode}.
|
|
247
|
+
*/
|
|
248
|
+
/** The most bytes one read returns. A larger file is refused, never truncated. */
|
|
249
|
+
export const MAX_READ_BYTES = 1024 * 1024;
|
|
250
|
+
/** The most entries one listing returns. A larger directory is refused, never truncated. */
|
|
251
|
+
export const MAX_LIST_ENTRIES = 1000;
|
|
252
|
+
/** Path requests a runner answers at once; one more is refused, not queued. */
|
|
253
|
+
export const MAX_PATH_REQUESTS_IN_FLIGHT = 8;
|
|
254
|
+
export const PATH_ERROR_CODES = [
|
|
255
|
+
/** Absolute, traversing (`..`), or carrying a NUL byte. */
|
|
256
|
+
'PATH_INVALID',
|
|
257
|
+
/** Resolves — through a symlink — outside the root. */
|
|
258
|
+
'PATH_OUTSIDE_ROOT',
|
|
259
|
+
'PATH_NOT_FOUND',
|
|
260
|
+
'NOT_A_DIRECTORY',
|
|
261
|
+
'NOT_A_FILE',
|
|
262
|
+
'FILE_TOO_LARGE',
|
|
263
|
+
'DIRECTORY_TOO_LARGE',
|
|
264
|
+
/** More than {@link MAX_PATH_REQUESTS_IN_FLIGHT} requests are being answered. */
|
|
265
|
+
'TOO_MANY_PATH_REQUESTS',
|
|
266
|
+
/** Any other filesystem failure; the message carries the cause. */
|
|
267
|
+
'PATH_UNREADABLE'
|
|
268
|
+
];
|
|
269
|
+
/**
|
|
270
|
+
* Why `requested` cannot name a path under a root, or `null` when it can.
|
|
271
|
+
* `''` (and `.`) name the root itself. This is syntax only: a path that passes
|
|
272
|
+
* may still resolve outside the root through a symlink, which only the side
|
|
273
|
+
* holding the filesystem can tell.
|
|
274
|
+
*/
|
|
275
|
+
export function relativePathProblem(requested) {
|
|
276
|
+
if (requested.includes('\0'))
|
|
277
|
+
return 'contains a NUL byte';
|
|
278
|
+
if (requested.startsWith('/') || requested.startsWith('\\') || /^[A-Za-z]:/.test(requested)) {
|
|
279
|
+
return 'is absolute';
|
|
280
|
+
}
|
|
281
|
+
if (requested.split(/[\\/]/).includes('..'))
|
|
282
|
+
return "traverses out of its root with '..'";
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
const PATH_ENTRY_KINDS = new Set(['file', 'directory', 'symlink', 'other']);
|
|
239
286
|
const FRAME_TYPES = new Set([
|
|
240
287
|
'open',
|
|
241
288
|
'input',
|
|
@@ -252,7 +299,15 @@ const FRAME_TYPES = new Set([
|
|
|
252
299
|
'attached',
|
|
253
300
|
'detached',
|
|
254
301
|
'reattached',
|
|
255
|
-
'enrolled'
|
|
302
|
+
'enrolled',
|
|
303
|
+
'path_list',
|
|
304
|
+
'path_read',
|
|
305
|
+
'path_listing',
|
|
306
|
+
'path_content',
|
|
307
|
+
'fs_list',
|
|
308
|
+
'fs_read',
|
|
309
|
+
'fs_listing',
|
|
310
|
+
'fs_content'
|
|
256
311
|
]);
|
|
257
312
|
/** Frame types that must carry a session id. */
|
|
258
313
|
const SESSION_FRAME_TYPES = new Set([
|
|
@@ -268,8 +323,31 @@ const SESSION_FRAME_TYPES = new Set([
|
|
|
268
323
|
'exit',
|
|
269
324
|
'scrollback',
|
|
270
325
|
'detached',
|
|
271
|
-
'reattached'
|
|
326
|
+
'reattached',
|
|
327
|
+
'fs_list',
|
|
328
|
+
'fs_read',
|
|
329
|
+
'fs_listing',
|
|
330
|
+
'fs_content'
|
|
331
|
+
]);
|
|
332
|
+
/** Frame types that answer, or ask, one request. */
|
|
333
|
+
const REQUEST_FRAME_TYPES = new Set([
|
|
334
|
+
'path_list',
|
|
335
|
+
'path_read',
|
|
336
|
+
'path_listing',
|
|
337
|
+
'path_content',
|
|
338
|
+
'fs_list',
|
|
339
|
+
'fs_read',
|
|
340
|
+
'fs_listing',
|
|
341
|
+
'fs_content'
|
|
272
342
|
]);
|
|
343
|
+
function validEntries(value) {
|
|
344
|
+
return (Array.isArray(value) &&
|
|
345
|
+
value.every(entry => isPlainObject(entry) &&
|
|
346
|
+
typeof entry.name === 'string' &&
|
|
347
|
+
typeof entry.kind === 'string' &&
|
|
348
|
+
PATH_ENTRY_KINDS.has(entry.kind) &&
|
|
349
|
+
(entry.size === undefined || (Number.isInteger(entry.size) && entry.size >= 0))));
|
|
350
|
+
}
|
|
273
351
|
const SIGNALS = new Set(SIGNAL_NAMES);
|
|
274
352
|
/**
|
|
275
353
|
* Terminal geometry a pty will accept. A resize is a client-supplied pair of
|
|
@@ -410,6 +488,10 @@ export function decodeFrame(raw) {
|
|
|
410
488
|
if (SESSION_FRAME_TYPES.has(type) && typeof frame.sessionId !== 'string') {
|
|
411
489
|
throw new Error(`machine-protocol: '${type}' frame is missing sessionId`);
|
|
412
490
|
}
|
|
491
|
+
if (REQUEST_FRAME_TYPES.has(type) &&
|
|
492
|
+
(typeof frame.requestId !== 'string' || frame.requestId.length === 0)) {
|
|
493
|
+
throw new Error(`machine-protocol: '${type}' frame is missing requestId`);
|
|
494
|
+
}
|
|
413
495
|
switch (type) {
|
|
414
496
|
case 'open':
|
|
415
497
|
if (typeof frame.command !== 'string' || frame.command.length === 0) {
|
|
@@ -475,6 +557,48 @@ export function decodeFrame(raw) {
|
|
|
475
557
|
if (typeof frame.message !== 'string') {
|
|
476
558
|
throw new Error("machine-protocol: 'error' frame is missing message");
|
|
477
559
|
}
|
|
560
|
+
if (frame.requestId !== undefined && typeof frame.requestId !== 'string') {
|
|
561
|
+
throw new Error("machine-protocol: 'error' frame requestId must be a string");
|
|
562
|
+
}
|
|
563
|
+
if (frame.code !== undefined && typeof frame.code !== 'string') {
|
|
564
|
+
throw new Error("machine-protocol: 'error' frame code must be a string");
|
|
565
|
+
}
|
|
566
|
+
break;
|
|
567
|
+
case 'path_list':
|
|
568
|
+
case 'path_read':
|
|
569
|
+
if (frame.root !== undefined && typeof frame.root !== 'string') {
|
|
570
|
+
throw new Error(`machine-protocol: '${type}' frame root must be a string`);
|
|
571
|
+
}
|
|
572
|
+
if (typeof frame.path !== 'string') {
|
|
573
|
+
throw new Error(`machine-protocol: '${type}' frame is missing path`);
|
|
574
|
+
}
|
|
575
|
+
break;
|
|
576
|
+
case 'fs_list':
|
|
577
|
+
case 'fs_read':
|
|
578
|
+
if (typeof frame.path !== 'string') {
|
|
579
|
+
throw new Error(`machine-protocol: '${type}' frame is missing path`);
|
|
580
|
+
}
|
|
581
|
+
break;
|
|
582
|
+
case 'path_listing':
|
|
583
|
+
case 'fs_listing':
|
|
584
|
+
if (!validEntries(frame.entries)) {
|
|
585
|
+
throw new Error(`machine-protocol: '${type}' frame has malformed entries`);
|
|
586
|
+
}
|
|
587
|
+
if (type === 'fs_listing' && typeof frame.path !== 'string') {
|
|
588
|
+
throw new Error("machine-protocol: 'fs_listing' frame is missing path");
|
|
589
|
+
}
|
|
590
|
+
break;
|
|
591
|
+
case 'path_content':
|
|
592
|
+
case 'fs_content':
|
|
593
|
+
if (typeof frame.data !== 'string' || !Number.isInteger(frame.size)) {
|
|
594
|
+
throw new Error(`machine-protocol: '${type}' frame is missing data/size`);
|
|
595
|
+
}
|
|
596
|
+
if (frame.size !== Buffer.from(frame.data, 'base64').length) {
|
|
597
|
+
throw new Error(`machine-protocol: '${type}' frame size does not match its data`);
|
|
598
|
+
}
|
|
599
|
+
if (type === 'fs_content' && typeof frame.path !== 'string') {
|
|
600
|
+
throw new Error("machine-protocol: 'fs_content' frame is missing path");
|
|
601
|
+
}
|
|
478
602
|
break;
|
|
479
603
|
case 'attached':
|
|
480
604
|
if (typeof frame.machineId !== 'string') {
|
package/index.d.ts
CHANGED
|
@@ -265,10 +265,103 @@ export interface ExitFrame {
|
|
|
265
265
|
export interface ErrorFrame {
|
|
266
266
|
type: 'error';
|
|
267
267
|
sessionId?: string;
|
|
268
|
+
/** The `path_*` / `fs_*` request this error answers, when it answers one. */
|
|
269
|
+
requestId?: string;
|
|
268
270
|
message: string;
|
|
269
271
|
/** A stable, machine-readable reason, where the sender has one. */
|
|
270
272
|
code?: string;
|
|
271
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* Reading paths under a root, read-only. Two vocabularies, like everything
|
|
276
|
+
* else: the runner leg (`path_list` / `path_read`) names a root relative to the
|
|
277
|
+
* runner's policy cwd and knows nothing about sessions; the client leg
|
|
278
|
+
* (`fs_list` / `fs_read`) names a session, and the relay maps it to that
|
|
279
|
+
* session's working tree after the tenant authorizes it. A request carries a
|
|
280
|
+
* `requestId` and is answered by exactly one frame with the same id: a
|
|
281
|
+
* listing, a content frame, or an `error` with a {@link PathErrorCode}.
|
|
282
|
+
*/
|
|
283
|
+
/** The most bytes one read returns. A larger file is refused, never truncated. */
|
|
284
|
+
export declare const MAX_READ_BYTES: number;
|
|
285
|
+
/** The most entries one listing returns. A larger directory is refused, never truncated. */
|
|
286
|
+
export declare const MAX_LIST_ENTRIES = 1000;
|
|
287
|
+
/** Path requests a runner answers at once; one more is refused, not queued. */
|
|
288
|
+
export declare const MAX_PATH_REQUESTS_IN_FLIGHT = 8;
|
|
289
|
+
export declare const PATH_ERROR_CODES: readonly ["PATH_INVALID", "PATH_OUTSIDE_ROOT", "PATH_NOT_FOUND", "NOT_A_DIRECTORY", "NOT_A_FILE", "FILE_TOO_LARGE", "DIRECTORY_TOO_LARGE", "TOO_MANY_PATH_REQUESTS", "PATH_UNREADABLE"];
|
|
290
|
+
export type PathErrorCode = (typeof PATH_ERROR_CODES)[number];
|
|
291
|
+
/**
|
|
292
|
+
* Why `requested` cannot name a path under a root, or `null` when it can.
|
|
293
|
+
* `''` (and `.`) name the root itself. This is syntax only: a path that passes
|
|
294
|
+
* may still resolve outside the root through a symlink, which only the side
|
|
295
|
+
* holding the filesystem can tell.
|
|
296
|
+
*/
|
|
297
|
+
export declare function relativePathProblem(requested: string): string | null;
|
|
298
|
+
export type PathEntryKind = 'file' | 'directory' | 'symlink' | 'other';
|
|
299
|
+
export interface PathEntry {
|
|
300
|
+
name: string;
|
|
301
|
+
/** What the entry itself is; a symlink is reported as one, not followed. */
|
|
302
|
+
kind: PathEntryKind;
|
|
303
|
+
/** Bytes, for a file. */
|
|
304
|
+
size?: number;
|
|
305
|
+
}
|
|
306
|
+
/** Relay → runner: list a directory under `root` (relative to the policy cwd). */
|
|
307
|
+
export interface PathListFrame {
|
|
308
|
+
type: 'path_list';
|
|
309
|
+
requestId: string;
|
|
310
|
+
/** The root `path` is confined to; omitted means the policy cwd itself. */
|
|
311
|
+
root?: string;
|
|
312
|
+
path: string;
|
|
313
|
+
}
|
|
314
|
+
/** Relay → runner: read a file under `root` (relative to the policy cwd). */
|
|
315
|
+
export interface PathReadFrame {
|
|
316
|
+
type: 'path_read';
|
|
317
|
+
requestId: string;
|
|
318
|
+
root?: string;
|
|
319
|
+
path: string;
|
|
320
|
+
}
|
|
321
|
+
/** Runner → relay: the entries of a `path_list`, sorted by name. */
|
|
322
|
+
export interface PathListingFrame {
|
|
323
|
+
type: 'path_listing';
|
|
324
|
+
requestId: string;
|
|
325
|
+
entries: PathEntry[];
|
|
326
|
+
}
|
|
327
|
+
/** Runner → relay: a `path_read`'s bytes, base64. */
|
|
328
|
+
export interface PathContentFrame {
|
|
329
|
+
type: 'path_content';
|
|
330
|
+
requestId: string;
|
|
331
|
+
data: string;
|
|
332
|
+
size: number;
|
|
333
|
+
}
|
|
334
|
+
/** Client → relay: list a directory in a session's working tree. */
|
|
335
|
+
export interface ListFilesFrame {
|
|
336
|
+
type: 'fs_list';
|
|
337
|
+
sessionId: string;
|
|
338
|
+
requestId: string;
|
|
339
|
+
path: string;
|
|
340
|
+
}
|
|
341
|
+
/** Client → relay: read a file in a session's working tree. */
|
|
342
|
+
export interface ReadFileFrame {
|
|
343
|
+
type: 'fs_read';
|
|
344
|
+
sessionId: string;
|
|
345
|
+
requestId: string;
|
|
346
|
+
path: string;
|
|
347
|
+
}
|
|
348
|
+
/** Relay → client: the answer to an `fs_list`. */
|
|
349
|
+
export interface FileListingFrame {
|
|
350
|
+
type: 'fs_listing';
|
|
351
|
+
sessionId: string;
|
|
352
|
+
requestId: string;
|
|
353
|
+
path: string;
|
|
354
|
+
entries: PathEntry[];
|
|
355
|
+
}
|
|
356
|
+
/** Relay → client: the answer to an `fs_read`, base64. */
|
|
357
|
+
export interface FileContentFrame {
|
|
358
|
+
type: 'fs_content';
|
|
359
|
+
sessionId: string;
|
|
360
|
+
requestId: string;
|
|
361
|
+
path: string;
|
|
362
|
+
data: string;
|
|
363
|
+
size: number;
|
|
364
|
+
}
|
|
272
365
|
/**
|
|
273
366
|
* Runner → relay: the scrollback a detaching session leaves behind, capped by
|
|
274
367
|
* the runner. The relay records it as the session's one interactive output row
|
|
@@ -325,9 +418,9 @@ export interface EnrolledFrame {
|
|
|
325
418
|
/** Epoch seconds at which {@link credential} stops verifying. */
|
|
326
419
|
expiresAt: number;
|
|
327
420
|
}
|
|
328
|
-
export type ClientToRunnerFrame = OpenFrame | InputFrame | ResizeFrame | SignalFrame | DetachFrame | ReattachFrame | CloseFrame;
|
|
329
|
-
export type RunnerToClientFrame = OutputFrame | AgentEventFrame | ExitFrame | ErrorFrame | ScrollbackFrame;
|
|
330
|
-
export type Frame = ClientToRunnerFrame | RunnerToClientFrame | AttachedFrame | DetachedFrame | ReattachedFrame | EnrolledFrame;
|
|
421
|
+
export type ClientToRunnerFrame = OpenFrame | InputFrame | ResizeFrame | SignalFrame | DetachFrame | ReattachFrame | CloseFrame | PathListFrame | PathReadFrame;
|
|
422
|
+
export type RunnerToClientFrame = OutputFrame | AgentEventFrame | ExitFrame | ErrorFrame | ScrollbackFrame | PathListingFrame | PathContentFrame;
|
|
423
|
+
export type Frame = ClientToRunnerFrame | RunnerToClientFrame | ListFilesFrame | ReadFileFrame | FileListingFrame | FileContentFrame | AttachedFrame | DetachedFrame | ReattachedFrame | EnrolledFrame;
|
|
331
424
|
/**
|
|
332
425
|
* Whether `value` looks like an {@link AgentEvent} — an object naming one of
|
|
333
426
|
* the event kinds. Shape is not checked; that is {@link assertAgentEvent}. A
|
package/index.js
CHANGED
|
@@ -36,12 +36,13 @@
|
|
|
36
36
|
// the tenant that issued it, and RLS decides everything after that. Addressing a
|
|
37
37
|
// database you have no credential for is simply a refusal.
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.LineSplitter = exports.MAX_LINE_LENGTH = exports.AGENT_CLI_COMMAND = exports.AGENT_EVENT_KINDS = exports.SIGNAL_NAMES = exports.AGENT_MODES = exports.attachSubprotocols = exports.ATTACH_SUBPROTOCOL = exports.clientAttachPath = exports.RUNNER_CONNECT_PATH = exports.HEADER_MACHINE_DATABASE = exports.HEADER_MACHINE_ID = void 0;
|
|
39
|
+
exports.PATH_ERROR_CODES = exports.MAX_PATH_REQUESTS_IN_FLIGHT = exports.MAX_LIST_ENTRIES = exports.MAX_READ_BYTES = exports.LineSplitter = exports.MAX_LINE_LENGTH = exports.AGENT_CLI_COMMAND = exports.AGENT_EVENT_KINDS = exports.SIGNAL_NAMES = exports.AGENT_MODES = exports.attachSubprotocols = exports.ATTACH_SUBPROTOCOL = exports.clientAttachPath = exports.RUNNER_CONNECT_PATH = exports.HEADER_MACHINE_DATABASE = exports.HEADER_MACHINE_ID = void 0;
|
|
40
40
|
exports.credentialFromSubprotocols = credentialFromSubprotocols;
|
|
41
41
|
exports.parseClientAttachPath = parseClientAttachPath;
|
|
42
42
|
exports.agentBinding = agentBinding;
|
|
43
43
|
exports.encodeApprovalDecisionLine = encodeApprovalDecisionLine;
|
|
44
44
|
exports.parseApprovalDecisionLine = parseApprovalDecisionLine;
|
|
45
|
+
exports.relativePathProblem = relativePathProblem;
|
|
45
46
|
exports.isAgentEventLike = isAgentEventLike;
|
|
46
47
|
exports.assertAgentEvent = assertAgentEvent;
|
|
47
48
|
exports.encodeFrame = encodeFrame;
|
|
@@ -251,6 +252,53 @@ exports.LineSplitter = LineSplitter;
|
|
|
251
252
|
function stripCarriageReturn(line) {
|
|
252
253
|
return line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
253
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* Reading paths under a root, read-only. Two vocabularies, like everything
|
|
257
|
+
* else: the runner leg (`path_list` / `path_read`) names a root relative to the
|
|
258
|
+
* runner's policy cwd and knows nothing about sessions; the client leg
|
|
259
|
+
* (`fs_list` / `fs_read`) names a session, and the relay maps it to that
|
|
260
|
+
* session's working tree after the tenant authorizes it. A request carries a
|
|
261
|
+
* `requestId` and is answered by exactly one frame with the same id: a
|
|
262
|
+
* listing, a content frame, or an `error` with a {@link PathErrorCode}.
|
|
263
|
+
*/
|
|
264
|
+
/** The most bytes one read returns. A larger file is refused, never truncated. */
|
|
265
|
+
exports.MAX_READ_BYTES = 1024 * 1024;
|
|
266
|
+
/** The most entries one listing returns. A larger directory is refused, never truncated. */
|
|
267
|
+
exports.MAX_LIST_ENTRIES = 1000;
|
|
268
|
+
/** Path requests a runner answers at once; one more is refused, not queued. */
|
|
269
|
+
exports.MAX_PATH_REQUESTS_IN_FLIGHT = 8;
|
|
270
|
+
exports.PATH_ERROR_CODES = [
|
|
271
|
+
/** Absolute, traversing (`..`), or carrying a NUL byte. */
|
|
272
|
+
'PATH_INVALID',
|
|
273
|
+
/** Resolves — through a symlink — outside the root. */
|
|
274
|
+
'PATH_OUTSIDE_ROOT',
|
|
275
|
+
'PATH_NOT_FOUND',
|
|
276
|
+
'NOT_A_DIRECTORY',
|
|
277
|
+
'NOT_A_FILE',
|
|
278
|
+
'FILE_TOO_LARGE',
|
|
279
|
+
'DIRECTORY_TOO_LARGE',
|
|
280
|
+
/** More than {@link MAX_PATH_REQUESTS_IN_FLIGHT} requests are being answered. */
|
|
281
|
+
'TOO_MANY_PATH_REQUESTS',
|
|
282
|
+
/** Any other filesystem failure; the message carries the cause. */
|
|
283
|
+
'PATH_UNREADABLE'
|
|
284
|
+
];
|
|
285
|
+
/**
|
|
286
|
+
* Why `requested` cannot name a path under a root, or `null` when it can.
|
|
287
|
+
* `''` (and `.`) name the root itself. This is syntax only: a path that passes
|
|
288
|
+
* may still resolve outside the root through a symlink, which only the side
|
|
289
|
+
* holding the filesystem can tell.
|
|
290
|
+
*/
|
|
291
|
+
function relativePathProblem(requested) {
|
|
292
|
+
if (requested.includes('\0'))
|
|
293
|
+
return 'contains a NUL byte';
|
|
294
|
+
if (requested.startsWith('/') || requested.startsWith('\\') || /^[A-Za-z]:/.test(requested)) {
|
|
295
|
+
return 'is absolute';
|
|
296
|
+
}
|
|
297
|
+
if (requested.split(/[\\/]/).includes('..'))
|
|
298
|
+
return "traverses out of its root with '..'";
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
const PATH_ENTRY_KINDS = new Set(['file', 'directory', 'symlink', 'other']);
|
|
254
302
|
const FRAME_TYPES = new Set([
|
|
255
303
|
'open',
|
|
256
304
|
'input',
|
|
@@ -267,7 +315,15 @@ const FRAME_TYPES = new Set([
|
|
|
267
315
|
'attached',
|
|
268
316
|
'detached',
|
|
269
317
|
'reattached',
|
|
270
|
-
'enrolled'
|
|
318
|
+
'enrolled',
|
|
319
|
+
'path_list',
|
|
320
|
+
'path_read',
|
|
321
|
+
'path_listing',
|
|
322
|
+
'path_content',
|
|
323
|
+
'fs_list',
|
|
324
|
+
'fs_read',
|
|
325
|
+
'fs_listing',
|
|
326
|
+
'fs_content'
|
|
271
327
|
]);
|
|
272
328
|
/** Frame types that must carry a session id. */
|
|
273
329
|
const SESSION_FRAME_TYPES = new Set([
|
|
@@ -283,8 +339,31 @@ const SESSION_FRAME_TYPES = new Set([
|
|
|
283
339
|
'exit',
|
|
284
340
|
'scrollback',
|
|
285
341
|
'detached',
|
|
286
|
-
'reattached'
|
|
342
|
+
'reattached',
|
|
343
|
+
'fs_list',
|
|
344
|
+
'fs_read',
|
|
345
|
+
'fs_listing',
|
|
346
|
+
'fs_content'
|
|
347
|
+
]);
|
|
348
|
+
/** Frame types that answer, or ask, one request. */
|
|
349
|
+
const REQUEST_FRAME_TYPES = new Set([
|
|
350
|
+
'path_list',
|
|
351
|
+
'path_read',
|
|
352
|
+
'path_listing',
|
|
353
|
+
'path_content',
|
|
354
|
+
'fs_list',
|
|
355
|
+
'fs_read',
|
|
356
|
+
'fs_listing',
|
|
357
|
+
'fs_content'
|
|
287
358
|
]);
|
|
359
|
+
function validEntries(value) {
|
|
360
|
+
return (Array.isArray(value) &&
|
|
361
|
+
value.every(entry => isPlainObject(entry) &&
|
|
362
|
+
typeof entry.name === 'string' &&
|
|
363
|
+
typeof entry.kind === 'string' &&
|
|
364
|
+
PATH_ENTRY_KINDS.has(entry.kind) &&
|
|
365
|
+
(entry.size === undefined || (Number.isInteger(entry.size) && entry.size >= 0))));
|
|
366
|
+
}
|
|
288
367
|
const SIGNALS = new Set(exports.SIGNAL_NAMES);
|
|
289
368
|
/**
|
|
290
369
|
* Terminal geometry a pty will accept. A resize is a client-supplied pair of
|
|
@@ -425,6 +504,10 @@ function decodeFrame(raw) {
|
|
|
425
504
|
if (SESSION_FRAME_TYPES.has(type) && typeof frame.sessionId !== 'string') {
|
|
426
505
|
throw new Error(`machine-protocol: '${type}' frame is missing sessionId`);
|
|
427
506
|
}
|
|
507
|
+
if (REQUEST_FRAME_TYPES.has(type) &&
|
|
508
|
+
(typeof frame.requestId !== 'string' || frame.requestId.length === 0)) {
|
|
509
|
+
throw new Error(`machine-protocol: '${type}' frame is missing requestId`);
|
|
510
|
+
}
|
|
428
511
|
switch (type) {
|
|
429
512
|
case 'open':
|
|
430
513
|
if (typeof frame.command !== 'string' || frame.command.length === 0) {
|
|
@@ -490,6 +573,48 @@ function decodeFrame(raw) {
|
|
|
490
573
|
if (typeof frame.message !== 'string') {
|
|
491
574
|
throw new Error("machine-protocol: 'error' frame is missing message");
|
|
492
575
|
}
|
|
576
|
+
if (frame.requestId !== undefined && typeof frame.requestId !== 'string') {
|
|
577
|
+
throw new Error("machine-protocol: 'error' frame requestId must be a string");
|
|
578
|
+
}
|
|
579
|
+
if (frame.code !== undefined && typeof frame.code !== 'string') {
|
|
580
|
+
throw new Error("machine-protocol: 'error' frame code must be a string");
|
|
581
|
+
}
|
|
582
|
+
break;
|
|
583
|
+
case 'path_list':
|
|
584
|
+
case 'path_read':
|
|
585
|
+
if (frame.root !== undefined && typeof frame.root !== 'string') {
|
|
586
|
+
throw new Error(`machine-protocol: '${type}' frame root must be a string`);
|
|
587
|
+
}
|
|
588
|
+
if (typeof frame.path !== 'string') {
|
|
589
|
+
throw new Error(`machine-protocol: '${type}' frame is missing path`);
|
|
590
|
+
}
|
|
591
|
+
break;
|
|
592
|
+
case 'fs_list':
|
|
593
|
+
case 'fs_read':
|
|
594
|
+
if (typeof frame.path !== 'string') {
|
|
595
|
+
throw new Error(`machine-protocol: '${type}' frame is missing path`);
|
|
596
|
+
}
|
|
597
|
+
break;
|
|
598
|
+
case 'path_listing':
|
|
599
|
+
case 'fs_listing':
|
|
600
|
+
if (!validEntries(frame.entries)) {
|
|
601
|
+
throw new Error(`machine-protocol: '${type}' frame has malformed entries`);
|
|
602
|
+
}
|
|
603
|
+
if (type === 'fs_listing' && typeof frame.path !== 'string') {
|
|
604
|
+
throw new Error("machine-protocol: 'fs_listing' frame is missing path");
|
|
605
|
+
}
|
|
606
|
+
break;
|
|
607
|
+
case 'path_content':
|
|
608
|
+
case 'fs_content':
|
|
609
|
+
if (typeof frame.data !== 'string' || !Number.isInteger(frame.size)) {
|
|
610
|
+
throw new Error(`machine-protocol: '${type}' frame is missing data/size`);
|
|
611
|
+
}
|
|
612
|
+
if (frame.size !== Buffer.from(frame.data, 'base64').length) {
|
|
613
|
+
throw new Error(`machine-protocol: '${type}' frame size does not match its data`);
|
|
614
|
+
}
|
|
615
|
+
if (type === 'fs_content' && typeof frame.path !== 'string') {
|
|
616
|
+
throw new Error("machine-protocol: 'fs_content' frame is missing path");
|
|
617
|
+
}
|
|
493
618
|
break;
|
|
494
619
|
case 'attached':
|
|
495
620
|
if (typeof frame.machineId !== 'string') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constructive-db/machine-protocol",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Wire protocol shared by the machine relay and the machine runner: the JSON frames a session is made of, and the codec that refuses malformed ones.",
|
|
5
5
|
"author": "Constructive <developers@constructive.io>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,5 +28,5 @@
|
|
|
28
28
|
"lint": "eslint . --fix",
|
|
29
29
|
"test": "jest"
|
|
30
30
|
},
|
|
31
|
-
"gitHead": "
|
|
31
|
+
"gitHead": "bdcd72c542def42905ba3729304bf47e55085137"
|
|
32
32
|
}
|