@constructive-db/machine-protocol 0.2.2 → 0.4.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 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.2.2",
3
+ "version": "0.4.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": "81eed19332cc231de0fde02bc6e87002357f9536"
31
+ "gitHead": "487c4ec9ee997d7da80584a3d41d7120d4354c54"
32
32
  }