herdr-remote 0.2.8 → 0.2.10

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/dist/tui.mjs CHANGED
@@ -76,7 +76,10 @@ var require_config = __commonJS({
76
76
  publicUrl: "",
77
77
  // Operator-run relay, e.g. wss://herdr.example.com (mode "remote" only).
78
78
  remoteUrl: "",
79
- maxPayloadBytes: 1024 * 1024,
79
+ // Kept in step with the relay package's own default: the CLI starts a
80
+ // relay of its own, and a smaller ceiling here would reject an upload the
81
+ // hosted relay accepts.
82
+ maxPayloadBytes: 5 * 1024 * 1024,
80
83
  maxClientsPerHost: 16,
81
84
  maxHosts: 1024,
82
85
  maxPendingHandshakes: 1024,
@@ -4012,7 +4015,7 @@ function About({ ctx }) {
4012
4015
  children: updateLabel
4013
4016
  }
4014
4017
  ) }),
4015
- /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.8" }) }),
4018
+ /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.10" }) }),
4016
4019
  /* @__PURE__ */ jsx10(Row, { label: t("about.configPath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.configPath)() }) }),
4017
4020
  /* @__PURE__ */ jsx10(Row, { label: t("about.statePath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.stateDir)() }) }),
4018
4021
  /* @__PURE__ */ jsx10(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: t("about.docs") }) }),
package/herdr-plugin.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  id = "herdr.remote.web"
2
2
  name = "Herdr Remote Web"
3
- version = "0.2.8"
3
+ version = "0.2.10"
4
4
  min_herdr_version = "0.9.0"
5
5
  description = "Browser access to Herdr workspaces via local or self-hosted relay"
6
6
  platforms = ["linux", "macos"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "Browser access to Herdr workspaces: plugin, host connector, and configuration TUI",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/config.js CHANGED
@@ -62,7 +62,10 @@ const DEFAULTS = {
62
62
  publicUrl: '',
63
63
  // Operator-run relay, e.g. wss://herdr.example.com (mode "remote" only).
64
64
  remoteUrl: '',
65
- maxPayloadBytes: 1024 * 1024,
65
+ // Kept in step with the relay package's own default: the CLI starts a
66
+ // relay of its own, and a smaller ceiling here would reject an upload the
67
+ // hosted relay accepts.
68
+ maxPayloadBytes: 5 * 1024 * 1024,
66
69
  maxClientsPerHost: 16,
67
70
  maxHosts: 1024,
68
71
  maxPendingHandshakes: 1024,
@@ -12,9 +12,16 @@ const { PtySession } = require('./pty-session');
12
12
  const { resolveHerdrCommand, verifyHerdrCommand, herdrNotFoundMessage } = require('./herdr-command');
13
13
  // The wire format lives in the relay package so both ends of the protocol are
14
14
  // generated from one definition.
15
- const { packStreamFrame, unpackStreamFrame, PROTOCOL_VERSION } = require('herdr-remote-relay/protocol');
15
+ const {
16
+ packStreamFrame,
17
+ unpackStreamFrame,
18
+ packStreamFrameV2,
19
+ FRAME_TYPE_OUTPUT,
20
+ PROTOCOL_VERSION,
21
+ } = require('herdr-remote-relay/protocol');
16
22
  const { resolveHostPalette } = require('./terminal-palette');
17
23
  const { EXIT_REPLACED, EXIT_AUTH_FAILED } = require('./exit-codes');
24
+ const { savePastedFile, cleanPastedDir } = require('./pasted-files');
18
25
 
19
26
  function randomId(prefix) {
20
27
  return `${prefix}-${crypto.randomBytes(9).toString('base64url')}`;
@@ -76,6 +83,8 @@ class HostConnector {
76
83
  : resolveHostPalette();
77
84
  this.ws = null;
78
85
  this.sessions = new Map();
86
+ this.streamIndexToId = new Map();
87
+ this.PtySession = options.PtySession || PtySession;
79
88
  this.fastFailures = 0;
80
89
  this.reconnectTimer = null;
81
90
  this.heartbeatTimer = null;
@@ -89,6 +98,7 @@ class HostConnector {
89
98
  || process.env.HERDR_REMOTE_HOST_LOCK
90
99
  || path.join(stateDir(), 'host-connector.lock');
91
100
  this.lockFd = null;
101
+ try { cleanPastedDir(); } catch {}
92
102
  }
93
103
 
94
104
  acquireLock() {
@@ -168,6 +178,8 @@ class HostConnector {
168
178
  this.ws = ws;
169
179
  ws.isAlive = true;
170
180
  ws.on('open', () => {
181
+ // Disable Nagle's algorithm to prevent small frames from stalling ~40ms when delayed ACK is active.
182
+ try { ws._socket?.setNoDelay(true); } catch {}
171
183
  ws.isAlive = true;
172
184
  this.ready = false;
173
185
  this.authFailure = false;
@@ -181,7 +193,7 @@ class HostConnector {
181
193
  platform: process.platform,
182
194
  arch: process.arch,
183
195
  terminalPalette: this.terminalPalette || null,
184
- capabilities: ['host_handoff', 'idle_heartbeat'],
196
+ capabilities: ['host_handoff', 'idle_heartbeat', 'binary_frame_v2'],
185
197
  });
186
198
  // The relay sends host_ready with the current browser count. No business
187
199
  // heartbeat is started until that message says somebody is watching.
@@ -244,7 +256,10 @@ class HostConnector {
244
256
  process.stderr.write(`herdr-remote host connector: invalid relay frame: ${error.message}\n`);
245
257
  return;
246
258
  }
247
- if (frame.type === 'input') this.sessions.get(frame.streamId)?.pty.write(frame.payload);
259
+ if (frame.type === 'input') {
260
+ const streamId = frame.version === 2 ? this.streamIndexToId.get(frame.streamIndex) : frame.streamId;
261
+ if (streamId) this.sessions.get(streamId)?.pty.write(frame.payload);
262
+ }
248
263
  return;
249
264
  }
250
265
  let message;
@@ -280,6 +295,7 @@ class HostConnector {
280
295
  if (message.type === 'session_start') this.startSession(message);
281
296
  else if (message.type === 'session_stop') this.stopSession(message.clientId || message.streamId);
282
297
  else if (message.type === 'resize') this.resizeSession(message);
298
+ else if (message.type === 'paste_file') this.handlePasteFile(message);
283
299
  }
284
300
 
285
301
  /**
@@ -309,6 +325,7 @@ class HostConnector {
309
325
  const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
310
326
  if (!streamId) return;
311
327
  this.stopSession(streamId);
328
+ const streamIndex = typeof message.streamIndex === 'number' ? message.streamIndex : null;
312
329
  const missingHerdr = this.ensureHerdrCommand();
313
330
  if (missingHerdr) {
314
331
  process.stderr.write(`herdr-remote host connector: ${missingHerdr}\n`);
@@ -320,7 +337,7 @@ class HostConnector {
320
337
  sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_socket_unavailable', message: socketInfo.reason });
321
338
  return;
322
339
  }
323
- const pty = new PtySession({
340
+ const pty = new this.PtySession({
324
341
  command: this.herdrCommand,
325
342
  args: this.herdrArgs,
326
343
  cwd: this.cwd,
@@ -328,34 +345,75 @@ class HostConnector {
328
345
  });
329
346
  const session = {
330
347
  id: streamId,
348
+ streamIndex,
331
349
  pty,
332
350
  cols: Number(message.cols) || PtySession.DEFAULT_COLS,
333
351
  rows: Number(message.rows) || PtySession.DEFAULT_ROWS,
334
352
  createdAt: new Date().toISOString(),
335
353
  startedAtMs: Date.now(),
336
354
  clientId: streamId,
355
+ pendingOutput: [],
356
+ flushImmediate: null,
357
+ };
358
+ const flushOutput = () => {
359
+ if (session.flushImmediate) {
360
+ clearImmediate(session.flushImmediate);
361
+ session.flushImmediate = null;
362
+ }
363
+ if (session.pendingOutput.length === 0) return;
364
+ const chunks = session.pendingOutput;
365
+ session.pendingOutput = [];
366
+ if (this.ws?.readyState === WebSocket.OPEN) {
367
+ const payload = Buffer.concat(chunks);
368
+ const frame = typeof session.streamIndex === 'number'
369
+ ? packStreamFrameV2(FRAME_TYPE_OUTPUT, session.streamIndex, payload)
370
+ : packStreamFrame('output', streamId, payload);
371
+ this.ws.send(frame);
372
+ }
337
373
  };
338
374
  try {
339
375
  pty.start({
340
376
  cols: session.cols,
341
377
  rows: session.rows,
342
378
  onData: (data) => {
343
- const payload = Buffer.from(data, 'utf8');
344
- if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(packStreamFrame('output', streamId, payload));
379
+ const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
380
+ if (chunk.length === 0) return;
381
+ session.pendingOutput.push(chunk);
382
+ if (!session.flushImmediate) {
383
+ // Coalesce burst output from the same tick into a single frame to reduce
384
+ // packet overhead without introducing timer latency.
385
+ session.flushImmediate = setImmediate(flushOutput);
386
+ }
345
387
  },
346
388
  onExit: ({ exitCode }) => {
347
389
  if (this.sessions.get(streamId) !== session) return;
348
390
  this.sessions.delete(streamId);
391
+ if (typeof session.streamIndex === 'number') {
392
+ this.streamIndexToId.delete(session.streamIndex);
393
+ }
394
+ if (session.flushImmediate) {
395
+ clearImmediate(session.flushImmediate);
396
+ session.flushImmediate = null;
397
+ }
398
+ session.pendingOutput = [];
349
399
  this.reportSessionExit(session, exitCode);
350
400
  this.sendHeartbeat();
351
401
  },
352
402
  });
353
403
  } catch (error) {
404
+ if (session.flushImmediate) {
405
+ clearImmediate(session.flushImmediate);
406
+ session.flushImmediate = null;
407
+ }
408
+ session.pendingOutput = [];
354
409
  sendJson(this.ws, { type: 'error', clientId: streamId, code: 'pty_start_failed', message: error.message });
355
410
  pty.kill();
356
411
  return;
357
412
  }
358
413
  this.sessions.set(streamId, session);
414
+ if (typeof streamIndex === 'number') {
415
+ this.streamIndexToId.set(streamIndex, streamId);
416
+ }
359
417
  sendJson(this.ws, { type: 'session_ready', clientId: streamId });
360
418
  this.sendHeartbeat();
361
419
  }
@@ -390,6 +448,14 @@ class HostConnector {
390
448
  const session = this.sessions.get(streamId);
391
449
  if (!session) return;
392
450
  this.sessions.delete(streamId);
451
+ if (typeof session.streamIndex === 'number') {
452
+ this.streamIndexToId.delete(session.streamIndex);
453
+ }
454
+ if (session.flushImmediate) {
455
+ clearImmediate(session.flushImmediate);
456
+ session.flushImmediate = null;
457
+ }
458
+ session.pendingOutput = [];
393
459
  session.pty.kill();
394
460
  this.sendHeartbeat();
395
461
  }
@@ -419,9 +485,39 @@ class HostConnector {
419
485
  session.pty.resize(session.cols, session.rows);
420
486
  }
421
487
 
488
+ handlePasteFile(message) {
489
+ const streamId = message.clientId || message.streamId;
490
+ try {
491
+ const savedPath = savePastedFile({
492
+ mime: message.mime,
493
+ dataBase64: message.dataBase64,
494
+ });
495
+ sendJson(this.ws, {
496
+ type: 'paste_file_ready',
497
+ clientId: streamId,
498
+ path: savedPath,
499
+ });
500
+ } catch (error) {
501
+ sendJson(this.ws, {
502
+ type: 'error',
503
+ clientId: streamId,
504
+ code: 'paste_file_write_failed',
505
+ message: error.message || 'Failed to save pasted file',
506
+ });
507
+ }
508
+ }
509
+
422
510
  destroySessions() {
423
- for (const session of this.sessions.values()) session.pty.kill();
511
+ for (const session of this.sessions.values()) {
512
+ if (session.flushImmediate) {
513
+ clearImmediate(session.flushImmediate);
514
+ session.flushImmediate = null;
515
+ }
516
+ session.pendingOutput = [];
517
+ session.pty.kill();
518
+ }
424
519
  this.sessions.clear();
520
+ this.streamIndexToId.clear();
425
521
  }
426
522
 
427
523
  sendHeartbeat(force = false) {
@@ -0,0 +1,176 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Secure file storage and validation for pasted clipboard images.
5
+ *
6
+ * Security requirements:
7
+ * 1. Pinned directory: writes strictly to path.join(stateDir(), 'pasted') (directory mode 0o700).
8
+ * 2. Unpredictable filename: generated via crypto.randomUUID(), never using client-provided strings.
9
+ * 3. Whitelisted extension: derived exclusively from verified MIME type.
10
+ * 4. Host-side size check: re-validated to enforce <= 3 MB independently of relay checks.
11
+ * 5. Magic bytes sniffing: inspects raw binary headers (PNG, JPEG, GIF, WebP) to prevent file-type spoofing.
12
+ * 6. File mode: files are created with mode 0o600.
13
+ * 7. Automatic pruning: caps at 20 files and 50 MB (oldest deleted first), removes >24h stale files at startup.
14
+ */
15
+
16
+ const crypto = require('node:crypto');
17
+ const fs = require('node:fs');
18
+ const path = require('node:path');
19
+ const { stateDir } = require('./config');
20
+ const { ensureDir } = require('./state');
21
+
22
+ const MAX_PASTE_BYTES = 3 * 1024 * 1024; // 3 MB raw payload
23
+ const MAX_SAVED_FILES = 20;
24
+ const MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB
25
+ const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
26
+
27
+ const MIME_CONFIG = {
28
+ 'image/png': {
29
+ ext: '.png',
30
+ check: (buf) =>
31
+ buf.length >= 8 &&
32
+ buf[0] === 0x89 &&
33
+ buf[1] === 0x50 &&
34
+ buf[2] === 0x4e &&
35
+ buf[3] === 0x47,
36
+ },
37
+ 'image/jpeg': {
38
+ ext: '.jpg',
39
+ check: (buf) =>
40
+ buf.length >= 3 &&
41
+ buf[0] === 0xff &&
42
+ buf[1] === 0xd8 &&
43
+ buf[2] === 0xff,
44
+ },
45
+ 'image/webp': {
46
+ ext: '.webp',
47
+ check: (buf) =>
48
+ buf.length >= 12 &&
49
+ buf[0] === 0x52 &&
50
+ buf[1] === 0x49 &&
51
+ buf[2] === 0x46 &&
52
+ buf[3] === 0x46 && // 'RIFF'
53
+ buf[8] === 0x57 &&
54
+ buf[9] === 0x45 &&
55
+ buf[10] === 0x42 &&
56
+ buf[11] === 0x50, // 'WEBP'
57
+ },
58
+ 'image/gif': {
59
+ ext: '.gif',
60
+ check: (buf) =>
61
+ buf.length >= 6 &&
62
+ buf[0] === 0x47 &&
63
+ buf[1] === 0x49 &&
64
+ buf[2] === 0x46 &&
65
+ buf[3] === 0x38, // 'GIF8'
66
+ },
67
+ };
68
+
69
+ function getPastedDir() {
70
+ const dir = path.join(stateDir(), 'pasted');
71
+ ensureDir(dir);
72
+ return dir;
73
+ }
74
+
75
+ /**
76
+ * Prunes the pasted directory:
77
+ * - Removes files older than maxAgeMs (default 24 hours).
78
+ * - Caps total file count (default 20) and total disk footprint (default 50 MB),
79
+ * deleting oldest files first.
80
+ */
81
+ function cleanPastedDir({
82
+ dir = getPastedDir(),
83
+ maxFiles = MAX_SAVED_FILES,
84
+ maxBytes = MAX_TOTAL_BYTES,
85
+ maxAgeMs = MAX_AGE_MS,
86
+ now = Date.now(),
87
+ } = {}) {
88
+ let entries;
89
+ try {
90
+ entries = fs.readdirSync(dir, { withFileTypes: true });
91
+ } catch {
92
+ return;
93
+ }
94
+
95
+ const fileInfos = [];
96
+
97
+ for (const entry of entries) {
98
+ if (!entry.isFile()) continue;
99
+ const fullPath = path.join(dir, entry.name);
100
+ try {
101
+ const stat = fs.statSync(fullPath);
102
+ if (now - stat.mtimeMs > maxAgeMs) {
103
+ try { fs.unlinkSync(fullPath); } catch {}
104
+ continue;
105
+ }
106
+ fileInfos.push({ path: fullPath, size: stat.size, mtimeMs: stat.mtimeMs });
107
+ } catch {}
108
+ }
109
+
110
+ // Sort oldest first
111
+ fileInfos.sort((a, b) => a.mtimeMs - b.mtimeMs);
112
+
113
+ let totalSize = fileInfos.reduce((sum, f) => sum + f.size, 0);
114
+
115
+ while (fileInfos.length > maxFiles || totalSize > maxBytes) {
116
+ const oldest = fileInfos.shift();
117
+ if (!oldest) break;
118
+ try {
119
+ fs.unlinkSync(oldest.path);
120
+ totalSize -= oldest.size;
121
+ } catch {}
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Validates base64 data, checks payload limits, sniffs magic bytes against declared MIME,
127
+ * writes to state storage with mode 0o600, and returns the absolute local path.
128
+ */
129
+ function savePastedFile({ mime, dataBase64, dir = getPastedDir() }) {
130
+ if (typeof mime !== 'string' || !Object.hasOwn(MIME_CONFIG, mime)) {
131
+ throw new Error(`unsupported MIME type: ${mime}`);
132
+ }
133
+ if (typeof dataBase64 !== 'string') {
134
+ throw new Error('missing or invalid dataBase64 payload');
135
+ }
136
+
137
+ const buf = Buffer.from(dataBase64, 'base64');
138
+ if (buf.length > MAX_PASTE_BYTES) {
139
+ throw new Error(`file size (${buf.length} bytes) exceeds maximum limit of 3 MB`);
140
+ }
141
+ if (buf.length === 0) {
142
+ throw new Error('file payload is empty');
143
+ }
144
+
145
+ const config = MIME_CONFIG[mime];
146
+ if (!config.check(buf)) {
147
+ throw new Error(`file magic bytes do not match declared MIME type ${mime}`);
148
+ }
149
+
150
+ // Prune before writing new file
151
+ cleanPastedDir({ dir });
152
+
153
+ const fileName = `${crypto.randomUUID()}${config.ext}`;
154
+ const filePath = path.join(dir, fileName);
155
+
156
+ fs.writeFileSync(filePath, buf, { mode: 0o600, flag: 'wx' });
157
+ try {
158
+ fs.chmodSync(filePath, 0o600);
159
+ } catch {}
160
+
161
+ // Prune again after writing to strictly observe count/size ceiling
162
+ cleanPastedDir({ dir });
163
+
164
+ return filePath;
165
+ }
166
+
167
+ module.exports = {
168
+ MAX_PASTE_BYTES,
169
+ MAX_SAVED_FILES,
170
+ MAX_TOTAL_BYTES,
171
+ MAX_AGE_MS,
172
+ MIME_CONFIG,
173
+ getPastedDir,
174
+ cleanPastedDir,
175
+ savePastedFile,
176
+ };