herdr-remote 0.2.7 → 0.2.9

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 dibin
3
+ Copyright (c) 2026 dibin666
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -45,6 +45,18 @@ herdr-remote plugin link
45
45
  - Settings: `~/.config/herdr-remote/config.json`
46
46
  - Runtime state: `~/.local/state/herdr-remote/runtime.json` (mode `0600`)
47
47
 
48
+ ## Locating Herdr
49
+
50
+ The `herdr` binary is looked up in `HERDR_BIN_PATH`, then `PATH`, then the usual
51
+ install directories (`~/.local/bin`, `~/.cargo/bin`, `~/bin`, `/opt/homebrew/bin`,
52
+ `/usr/local/bin`, …). `herdr-remote keepalive install` writes the binary it found
53
+ and the current `PATH` into the systemd unit or launchd agent, because a service
54
+ manager does not inherit your shell's environment.
55
+
56
+ `herdr-remote status --json` reports `host.herdrCommand` and `host.herdrCommandFound`.
57
+ If it was not found, set `HERDR_BIN_PATH` to the full path and reinstall the
58
+ keep-alive service.
59
+
48
60
  ## License
49
61
 
50
62
  MIT
package/README.zh-CN.md CHANGED
@@ -45,6 +45,16 @@ herdr-remote plugin link
45
45
  - 配置文件:`~/.config/herdr-remote/config.json`
46
46
  - 运行状态:`~/.local/state/herdr-remote/runtime.json` (权限 `0600`)
47
47
 
48
+ ## herdr 命令的查找方式
49
+
50
+ 依次查找 `HERDR_BIN_PATH`、`PATH`,以及常见安装目录(`~/.local/bin`、`~/.cargo/bin`、
51
+ `~/bin`、`/opt/homebrew/bin`、`/usr/local/bin` 等)。服务管理器不会继承 shell 环境,
52
+ 因此 `herdr-remote keepalive install` 会把找到的可执行文件路径和当前 `PATH` 写入
53
+ systemd unit 或 launchd plist。
54
+
55
+ `herdr-remote status --json` 会输出 `host.herdrCommand` 与 `host.herdrCommandFound`。
56
+ 若显示未找到,请将 `HERDR_BIN_PATH` 设为完整路径后重新安装保活服务。
57
+
48
58
  ## 开源协议
49
59
 
50
60
  MIT
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,
@@ -526,7 +529,7 @@ var require_en = __commonJS({
526
529
  "herdr.registerDone": "Registered: {path}",
527
530
  "herdr.unregisterDone": "Plugin unregistered.",
528
531
  "herdr.registerFailed": "Registration failed: {message}",
529
- "herdr.cliMissing": "Command herdr not found in PATH.",
532
+ "herdr.cliMissing": "Command herdr not found. Set HERDR_BIN_PATH to its full path.",
530
533
  "about.title": "Language & about",
531
534
  "about.language": "Interface language",
532
535
  "about.languageAuto": "Follow system ({detected})",
@@ -751,7 +754,7 @@ var require_zh = __commonJS({
751
754
  "herdr.registerDone": "\u5DF2\u6CE8\u518C\uFF1A{path}",
752
755
  "herdr.unregisterDone": "\u5DF2\u53D6\u6D88\u6CE8\u518C\u3002",
753
756
  "herdr.registerFailed": "\u6CE8\u518C\u5931\u8D25\uFF1A{message}",
754
- "herdr.cliMissing": "PATH \u4E2D\u672A\u627E\u5230 herdr \u547D\u4EE4\u3002",
757
+ "herdr.cliMissing": "\u672A\u627E\u5230 herdr \u547D\u4EE4\uFF0C\u53EF\u5C06 HERDR_BIN_PATH \u8BBE\u4E3A\u5176\u5B8C\u6574\u8DEF\u5F84\u3002",
755
758
  "about.title": "\u8BED\u8A00\u4E0E\u5173\u4E8E",
756
759
  "about.language": "\u754C\u9762\u8BED\u8A00",
757
760
  "about.languageAuto": "\u8DDF\u968F\u7CFB\u7EDF\uFF08{detected}\uFF09",
@@ -1459,10 +1462,104 @@ var require_socket_discovery = __commonJS({
1459
1462
  var require_herdr_command = __commonJS({
1460
1463
  "src/herdr-command.js"(exports, module) {
1461
1464
  "use strict";
1462
- function resolveHerdrCommand() {
1463
- return process.env.HERDR_BIN_PATH || "herdr";
1465
+ var fs = __require("node:fs");
1466
+ var os = __require("node:os");
1467
+ var path = __require("node:path");
1468
+ var COMMAND_NAME = "herdr";
1469
+ var FALLBACK_DIRECTORIES = [
1470
+ "~/.local/bin",
1471
+ "~/.cargo/bin",
1472
+ "~/bin",
1473
+ "/opt/homebrew/bin",
1474
+ "/usr/local/bin",
1475
+ "/home/linuxbrew/.linuxbrew/bin",
1476
+ "/usr/bin"
1477
+ ];
1478
+ function expandHome(directory, home) {
1479
+ if (directory === "~") return home;
1480
+ if (directory.startsWith("~/")) return path.join(home, directory.slice(2));
1481
+ return directory;
1482
+ }
1483
+ function candidateNames(base = COMMAND_NAME) {
1484
+ return process.platform === "win32" ? [base, `${base}.exe`, `${base}.cmd`, `${base}.bat`] : [base];
1485
+ }
1486
+ function isExecutableFile(candidate) {
1487
+ try {
1488
+ if (!fs.statSync(candidate).isFile()) return false;
1489
+ fs.accessSync(candidate, fs.constants.X_OK);
1490
+ return true;
1491
+ } catch {
1492
+ return false;
1493
+ }
1494
+ }
1495
+ function findInDirectory(directory, names = candidateNames()) {
1496
+ if (!directory) return null;
1497
+ for (const name of names) {
1498
+ const candidate = path.join(directory, name);
1499
+ if (isExecutableFile(candidate)) return candidate;
1500
+ }
1501
+ return null;
1502
+ }
1503
+ function findOnSearchPath(searchPath, names = candidateNames()) {
1504
+ for (const entry of String(searchPath || "").split(path.delimiter)) {
1505
+ const found = findInDirectory(entry.trim(), names);
1506
+ if (found) return found;
1507
+ }
1508
+ return null;
1464
1509
  }
1465
- module.exports = { resolveHerdrCommand };
1510
+ function fallbackDirectories(home = os.homedir()) {
1511
+ return FALLBACK_DIRECTORIES.map((directory) => expandHome(directory, home));
1512
+ }
1513
+ function looksLikePath(value) {
1514
+ return value.includes("/") || value.includes(path.sep);
1515
+ }
1516
+ function resolveOverride(value, searchPath) {
1517
+ const trimmed = String(value || "").trim();
1518
+ if (!trimmed) return null;
1519
+ if (looksLikePath(trimmed)) {
1520
+ const absolute = path.resolve(trimmed);
1521
+ if (isExecutableFile(absolute)) return absolute;
1522
+ return findInDirectory(absolute);
1523
+ }
1524
+ return findOnSearchPath(searchPath, candidateNames(trimmed));
1525
+ }
1526
+ function findHerdrCommand({ env = process.env, home = os.homedir(), directories = fallbackDirectories(home) } = {}) {
1527
+ const override = resolveOverride(env.HERDR_BIN_PATH, env.PATH);
1528
+ if (override) return { command: override, source: "env", found: true };
1529
+ const onPath = findOnSearchPath(env.PATH);
1530
+ if (onPath) return { command: onPath, source: "path", found: true };
1531
+ for (const directory of directories) {
1532
+ const found = findInDirectory(directory);
1533
+ if (found) return { command: found, source: "fallback", found: true };
1534
+ }
1535
+ return { command: COMMAND_NAME, source: "unresolved", found: false };
1536
+ }
1537
+ function resolveHerdrCommand(options) {
1538
+ return findHerdrCommand(options).command;
1539
+ }
1540
+ function verifyHerdrCommand(command, options = {}) {
1541
+ if (typeof command === "string" && looksLikePath(command) && isExecutableFile(command)) {
1542
+ return { command, source: "verified", found: true };
1543
+ }
1544
+ return findHerdrCommand(options);
1545
+ }
1546
+ function herdrNotFoundMessage({ env = process.env, home = os.homedir(), directories = fallbackDirectories(home) } = {}) {
1547
+ const override = String(env.HERDR_BIN_PATH || "").trim();
1548
+ const parts = [`Herdr executable "${COMMAND_NAME}" was not found.`];
1549
+ if (override) parts.push(`HERDR_BIN_PATH=${override} does not point at an executable.`);
1550
+ parts.push(directories.length ? `Searched PATH and ${directories.join(", ")}.` : "Searched PATH.");
1551
+ parts.push("Install Herdr or set HERDR_BIN_PATH to its full path, then restart herdr-remote.");
1552
+ return parts.join(" ");
1553
+ }
1554
+ module.exports = {
1555
+ COMMAND_NAME,
1556
+ FALLBACK_DIRECTORIES,
1557
+ fallbackDirectories,
1558
+ findHerdrCommand,
1559
+ herdrNotFoundMessage,
1560
+ resolveHerdrCommand,
1561
+ verifyHerdrCommand
1562
+ };
1466
1563
  }
1467
1564
  });
1468
1565
 
@@ -1496,7 +1593,7 @@ var require_service = __commonJS({
1496
1593
  } = require_terminal_palette();
1497
1594
  var { resolveSocketPath } = require_socket_discovery();
1498
1595
  var { preferredLanAddress: preferredLanAddress2 } = require_net_interfaces();
1499
- var { resolveHerdrCommand } = require_herdr_command();
1596
+ var { findHerdrCommand, resolveHerdrCommand } = require_herdr_command();
1500
1597
  var RUNTIME_VERSION = 2;
1501
1598
  function pidAlive(pid) {
1502
1599
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -1802,6 +1899,7 @@ var require_service = __commonJS({
1802
1899
  health = { ok: false, message: error.message };
1803
1900
  }
1804
1901
  const socketPath = resolveSocketPath(config.herdr.socketPath);
1902
+ const herdr = findHerdrCommand();
1805
1903
  return {
1806
1904
  ok: true,
1807
1905
  mode: config.relay.mode,
@@ -1819,7 +1917,10 @@ var require_service = __commonJS({
1819
1917
  alive: pidAlive(state.hostPid),
1820
1918
  hostId: state.hostId || null,
1821
1919
  socketPath,
1822
- socketExists: Boolean(socketPath) && fs.existsSync(socketPath)
1920
+ socketExists: Boolean(socketPath) && fs.existsSync(socketPath),
1921
+ herdrCommand: herdr.command,
1922
+ herdrCommandFound: herdr.found,
1923
+ herdrCommandSource: herdr.source
1823
1924
  },
1824
1925
  publicUrl: resolvePublicUrl2(config, lanAddress),
1825
1926
  startedAt: state.startedAt || null
@@ -1896,6 +1997,7 @@ var require_keepalive = __commonJS({
1896
1997
  var { PACKAGE_ROOT, loadConfig: loadConfig2, stateDir: stateDir2 } = require_config();
1897
1998
  var { ensureDir, readJson, writeJsonAtomic } = require_state();
1898
1999
  var { logPath, pidAlive } = require_service();
2000
+ var { findHerdrCommand } = require_herdr_command();
1899
2001
  var SYSTEMD_UNIT_NAME = "herdr-remote.service";
1900
2002
  var LAUNCHD_LABEL = "dev.herdr.remote";
1901
2003
  var FALLBACK_PID_FILE = "supervisor.pid";
@@ -1936,8 +2038,28 @@ var require_keepalive = __commonJS({
1936
2038
  }
1937
2039
  return "supervisor";
1938
2040
  }
2041
+ function servicePath({ env = process.env, herdrCommand = null } = {}) {
2042
+ const entries = String(env.PATH || "").split(path.delimiter).map((entry) => entry.trim()).filter(Boolean);
2043
+ if (herdrCommand) entries.push(path.dirname(herdrCommand));
2044
+ const seen = /* @__PURE__ */ new Set();
2045
+ const unique = entries.filter((entry) => seen.has(entry) ? false : seen.add(entry));
2046
+ return unique.join(path.delimiter);
2047
+ }
2048
+ function serviceEnvironment({ env = process.env, home = os.homedir(), directories } = {}) {
2049
+ const environment = {};
2050
+ const herdr = findHerdrCommand({ env, home, ...directories ? { directories } : {} });
2051
+ if (herdr.found) environment.HERDR_BIN_PATH = herdr.command;
2052
+ const searchPath = servicePath({ env, herdrCommand: herdr.found ? herdr.command : null });
2053
+ if (searchPath) environment.PATH = searchPath;
2054
+ return environment;
2055
+ }
2056
+ function systemdEnvironmentLine(key, value) {
2057
+ const text = String(value).replace(/[\r\n]+/g, " ").replace(/%/g, "%%");
2058
+ if (/^[\w@+=:,./-]*$/.test(text)) return `Environment=${key}=${text}`;
2059
+ return `Environment="${key}=${text.replace(/([\\"])/g, "\\$1")}"`;
2060
+ }
1939
2061
  function renderSystemdUnit({ nodePath = process.execPath, entryPoint = cliEntryPoint(), environment = {} } = {}) {
1940
- const environmentLines = Object.entries(environment).map(([key, value]) => `Environment=${key}=${value}`).join("\n");
2062
+ const environmentLines = Object.entries(environment).map(([key, value]) => systemdEnvironmentLine(key, value)).join("\n");
1941
2063
  return `[Unit]
1942
2064
  Description=Herdr Remote (relay and host connector)
1943
2065
  Documentation=https://github.com/herdr/herdr-remote
@@ -2025,7 +2147,7 @@ ${environmentBlock} <key>StandardOutPath</key>
2025
2147
  function systemdInstall() {
2026
2148
  const unitPath = systemdUnitPath();
2027
2149
  ensureDir(path.dirname(unitPath));
2028
- fs.writeFileSync(unitPath, renderSystemdUnit(), { mode: 420 });
2150
+ fs.writeFileSync(unitPath, renderSystemdUnit({ environment: serviceEnvironment() }), { mode: 420 });
2029
2151
  const reload = systemctl(["daemon-reload"]);
2030
2152
  if (reload.status !== 0) {
2031
2153
  throw new Error(`systemctl --user daemon-reload failed: ${String(reload.stderr || "").trim()}`);
@@ -2065,7 +2187,7 @@ ${environmentBlock} <key>StandardOutPath</key>
2065
2187
  const plistPath = launchdPlistPath();
2066
2188
  ensureDir(path.dirname(plistPath));
2067
2189
  ensureDir(stateDir2());
2068
- fs.writeFileSync(plistPath, renderLaunchdPlist(), { mode: 420 });
2190
+ fs.writeFileSync(plistPath, renderLaunchdPlist({ environment: serviceEnvironment() }), { mode: 420 });
2069
2191
  spawnSync("launchctl", ["bootout", `${launchdDomainTarget()}/${LAUNCHD_LABEL}`], { stdio: "ignore" });
2070
2192
  const result = spawnSync("launchctl", ["bootstrap", launchdDomainTarget(), plistPath], { encoding: "utf8" });
2071
2193
  if (result.status !== 0) {
@@ -2104,7 +2226,7 @@ ${environmentBlock} <key>StandardOutPath</key>
2104
2226
  try {
2105
2227
  const child = spawn(process.execPath, [cliEntryPoint(), "run", "--daemon"], {
2106
2228
  cwd: PACKAGE_ROOT,
2107
- env: { ...process.env, HERDR_REMOTE_SERVICE: "1" },
2229
+ env: { ...process.env, HERDR_REMOTE_SERVICE: "1", ...serviceEnvironment() },
2108
2230
  detached: true,
2109
2231
  stdio: ["ignore", logFd, logFd]
2110
2232
  });
@@ -2209,6 +2331,9 @@ ${environmentBlock} <key>StandardOutPath</key>
2209
2331
  renderSystemdUnit,
2210
2332
  renderLaunchdPlist,
2211
2333
  escapeXml,
2334
+ servicePath,
2335
+ serviceEnvironment,
2336
+ systemdEnvironmentLine,
2212
2337
  status,
2213
2338
  install,
2214
2339
  uninstall,
@@ -2275,17 +2400,18 @@ var require_herdr_plugin = __commonJS({
2275
2400
  var path = __require("node:path");
2276
2401
  var { spawnSync } = __require("node:child_process");
2277
2402
  var { PACKAGE_ROOT } = require_config();
2403
+ var { resolveHerdrCommand } = require_herdr_command();
2278
2404
  var PLUGIN_ID = "herdr.remote.web";
2279
2405
  var MANIFEST_NAME = "herdr-plugin.toml";
2280
2406
  function manifestPath() {
2281
2407
  return path.join(PACKAGE_ROOT, MANIFEST_NAME);
2282
2408
  }
2283
2409
  function herdrAvailable() {
2284
- const result = spawnSync("herdr", ["--version"], { stdio: "ignore" });
2410
+ const result = spawnSync(resolveHerdrCommand(), ["--version"], { stdio: "ignore" });
2285
2411
  return result.status === 0 || result.status === 1;
2286
2412
  }
2287
2413
  function runHerdr(args, { timeout = 15e3 } = {}) {
2288
- const result = spawnSync("herdr", args, { encoding: "utf8", timeout });
2414
+ const result = spawnSync(resolveHerdrCommand(), args, { encoding: "utf8", timeout });
2289
2415
  if (result.error && result.error.code === "ENOENT") {
2290
2416
  const error = new Error("herdr command not found");
2291
2417
  error.code = "HERDR_NOT_FOUND";
@@ -3889,7 +4015,7 @@ function About({ ctx }) {
3889
4015
  children: updateLabel
3890
4016
  }
3891
4017
  ) }),
3892
- /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.7" }) }),
4018
+ /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.9" }) }),
3893
4019
  /* @__PURE__ */ jsx10(Row, { label: t("about.configPath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.configPath)() }) }),
3894
4020
  /* @__PURE__ */ jsx10(Row, { label: t("about.statePath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.stateDir)() }) }),
3895
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.7"
3
+ version = "0.2.9"
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.7",
3
+ "version": "0.2.9",
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,
@@ -3,10 +3,157 @@
3
3
  // How a spawned Herdr is located.
4
4
  //
5
5
  // `HERDR_BIN_PATH` is what the service layer hands to the host connector, and
6
- // it is also the escape hatch for an install that is not on `PATH`.
6
+ // it is also the escape hatch for an install that is not on `PATH`. Neither is
7
+ // enough on its own once a service manager is in the picture: `systemd --user`
8
+ // and `launchd` start their units with a minimal `PATH` that contains none of
9
+ // the directories a user-level install writes to. A bare `herdr` then reaches
10
+ // `execvp(3)` inside the forked PTY child, which prints
11
+ // "execvp(3) failed.: No such file or directory" and exits — the failure the
12
+ // browser sees as an endless reconnect loop.
13
+ //
14
+ // So the name is resolved to an absolute path here, ahead of any spawn:
15
+ // an honoured override first, then `PATH`, then the handful of directories
16
+ // installers actually use.
17
+
18
+ const fs = require('node:fs');
19
+ const os = require('node:os');
20
+ const path = require('node:path');
21
+
22
+ const COMMAND_NAME = 'herdr';
23
+
24
+ /** Where a user-level install lands, in the order we trust it. */
25
+ const FALLBACK_DIRECTORIES = [
26
+ '~/.local/bin',
27
+ '~/.cargo/bin',
28
+ '~/bin',
29
+ '/opt/homebrew/bin',
30
+ '/usr/local/bin',
31
+ '/home/linuxbrew/.linuxbrew/bin',
32
+ '/usr/bin',
33
+ ];
34
+
35
+ function expandHome(directory, home) {
36
+ if (directory === '~') return home;
37
+ if (directory.startsWith('~/')) return path.join(home, directory.slice(2));
38
+ return directory;
39
+ }
40
+
41
+ /** Windows keeps the executable bit in the extension instead of the mode. */
42
+ function candidateNames(base = COMMAND_NAME) {
43
+ return process.platform === 'win32'
44
+ ? [base, `${base}.exe`, `${base}.cmd`, `${base}.bat`]
45
+ : [base];
46
+ }
47
+
48
+ function isExecutableFile(candidate) {
49
+ try {
50
+ if (!fs.statSync(candidate).isFile()) return false;
51
+ fs.accessSync(candidate, fs.constants.X_OK);
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ function findInDirectory(directory, names = candidateNames()) {
59
+ if (!directory) return null;
60
+ for (const name of names) {
61
+ const candidate = path.join(directory, name);
62
+ if (isExecutableFile(candidate)) return candidate;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function findOnSearchPath(searchPath, names = candidateNames()) {
68
+ for (const entry of String(searchPath || '').split(path.delimiter)) {
69
+ const found = findInDirectory(entry.trim(), names);
70
+ if (found) return found;
71
+ }
72
+ return null;
73
+ }
74
+
75
+ function fallbackDirectories(home = os.homedir()) {
76
+ return FALLBACK_DIRECTORIES.map((directory) => expandHome(directory, home));
77
+ }
78
+
79
+ function looksLikePath(value) {
80
+ return value.includes('/') || value.includes(path.sep);
81
+ }
82
+
83
+ /**
84
+ * Honour `HERDR_BIN_PATH`.
85
+ *
86
+ * It is documented as the executable, but a directory is an easy thing to
87
+ * write there, and a bare name — someone copying `herdr` into a unit file —
88
+ * still has to go through the search. A value that resolves to nothing is
89
+ * treated as absent rather than fatal: a stale override left behind by a
90
+ * moved install is precisely the case we are trying to survive.
91
+ */
92
+ function resolveOverride(value, searchPath) {
93
+ const trimmed = String(value || '').trim();
94
+ if (!trimmed) return null;
95
+ if (looksLikePath(trimmed)) {
96
+ const absolute = path.resolve(trimmed);
97
+ if (isExecutableFile(absolute)) return absolute;
98
+ return findInDirectory(absolute);
99
+ }
100
+ return findOnSearchPath(searchPath, candidateNames(trimmed));
101
+ }
102
+
103
+ /**
104
+ * Everything known about where Herdr is, so callers can report it and not only
105
+ * spawn it.
106
+ *
107
+ * `source` is `env` for an honoured `HERDR_BIN_PATH`, `path` for a hit on
108
+ * `PATH`, `fallback` for one of the well-known install directories, and
109
+ * `unresolved` when nothing matched. In that last case `command` is still the
110
+ * bare name: `PATH` may be right in a way we cannot see from here, and letting
111
+ * the spawn proceed keeps the old behaviour for anyone relying on it.
112
+ */
113
+ function findHerdrCommand({ env = process.env, home = os.homedir(), directories = fallbackDirectories(home) } = {}) {
114
+ const override = resolveOverride(env.HERDR_BIN_PATH, env.PATH);
115
+ if (override) return { command: override, source: 'env', found: true };
116
+ const onPath = findOnSearchPath(env.PATH);
117
+ if (onPath) return { command: onPath, source: 'path', found: true };
118
+ for (const directory of directories) {
119
+ const found = findInDirectory(directory);
120
+ if (found) return { command: found, source: 'fallback', found: true };
121
+ }
122
+ return { command: COMMAND_NAME, source: 'unresolved', found: false };
123
+ }
124
+
125
+ function resolveHerdrCommand(options) {
126
+ return findHerdrCommand(options).command;
127
+ }
128
+
129
+ /**
130
+ * Re-check a command resolved earlier. A connector outlives the install that
131
+ * was missing when it started, and an absolute path outlives the binary it
132
+ * pointed at, so neither answer stays true for the life of the service.
133
+ */
134
+ function verifyHerdrCommand(command, options = {}) {
135
+ if (typeof command === 'string' && looksLikePath(command) && isExecutableFile(command)) {
136
+ return { command, source: 'verified', found: true };
137
+ }
138
+ return findHerdrCommand(options);
139
+ }
7
140
 
8
- function resolveHerdrCommand() {
9
- return process.env.HERDR_BIN_PATH || 'herdr';
141
+ /** The one message a user needs to fix a missing install themselves. */
142
+ function herdrNotFoundMessage({ env = process.env, home = os.homedir(), directories = fallbackDirectories(home) } = {}) {
143
+ const override = String(env.HERDR_BIN_PATH || '').trim();
144
+ const parts = [`Herdr executable "${COMMAND_NAME}" was not found.`];
145
+ if (override) parts.push(`HERDR_BIN_PATH=${override} does not point at an executable.`);
146
+ parts.push(directories.length ? `Searched PATH and ${directories.join(', ')}.` : 'Searched PATH.');
147
+ parts.push('Install Herdr or set HERDR_BIN_PATH to its full path, then restart herdr-remote.');
148
+ return parts.join(' ');
10
149
  }
11
150
 
12
- module.exports = { resolveHerdrCommand };
151
+ module.exports = {
152
+ COMMAND_NAME,
153
+ FALLBACK_DIRECTORIES,
154
+ fallbackDirectories,
155
+ findHerdrCommand,
156
+ herdrNotFoundMessage,
157
+ resolveHerdrCommand,
158
+ verifyHerdrCommand,
159
+ };
@@ -10,6 +10,7 @@ const fs = require('node:fs');
10
10
  const path = require('node:path');
11
11
  const { spawnSync } = require('node:child_process');
12
12
  const { PACKAGE_ROOT } = require('./config');
13
+ const { resolveHerdrCommand } = require('./herdr-command');
13
14
 
14
15
  const PLUGIN_ID = 'herdr.remote.web';
15
16
  const MANIFEST_NAME = 'herdr-plugin.toml';
@@ -18,13 +19,16 @@ function manifestPath() {
18
19
  return path.join(PACKAGE_ROOT, MANIFEST_NAME);
19
20
  }
20
21
 
22
+ // Registration also runs from `herdr plugin` actions and from the service
23
+ // manager, neither of which is guaranteed the PATH the user installed with, so
24
+ // the command is resolved rather than named.
21
25
  function herdrAvailable() {
22
- const result = spawnSync('herdr', ['--version'], { stdio: 'ignore' });
26
+ const result = spawnSync(resolveHerdrCommand(), ['--version'], { stdio: 'ignore' });
23
27
  return result.status === 0 || result.status === 1;
24
28
  }
25
29
 
26
30
  function runHerdr(args, { timeout = 15_000 } = {}) {
27
- const result = spawnSync('herdr', args, { encoding: 'utf8', timeout });
31
+ const result = spawnSync(resolveHerdrCommand(), args, { encoding: 'utf8', timeout });
28
32
  if (result.error && result.error.code === 'ENOENT') {
29
33
  const error = new Error('herdr command not found');
30
34
  error.code = 'HERDR_NOT_FOUND';
@@ -9,12 +9,13 @@ const { loadConfig, hostWebSocketUrl, resolveHostRelayUrl, stateDir } = require(
9
9
  const { ensureDir } = require('./state');
10
10
  const { resolveSocketPath, inspectSocket } = require('./socket-discovery');
11
11
  const { PtySession } = require('./pty-session');
12
- const { resolveHerdrCommand } = require('./herdr-command');
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
15
  const { packStreamFrame, unpackStreamFrame, PROTOCOL_VERSION } = require('herdr-remote-relay/protocol');
16
16
  const { resolveHostPalette } = require('./terminal-palette');
17
17
  const { EXIT_REPLACED, EXIT_AUTH_FAILED } = require('./exit-codes');
18
+ const { savePastedFile, cleanPastedDir } = require('./pasted-files');
18
19
 
19
20
  function randomId(prefix) {
20
21
  return `${prefix}-${crypto.randomBytes(9).toString('base64url')}`;
@@ -40,6 +41,14 @@ function pidAlive(pid) {
40
41
  }
41
42
 
42
43
  class HostConnector {
44
+ /**
45
+ * A session that dies this fast never really started. Anything longer is a
46
+ * session the user actually used and then left.
47
+ */
48
+ static FAST_FAILURE_MS = 1500;
49
+ /** How many broken starts in a row before we stop calling them exits. */
50
+ static FAST_FAILURE_LIMIT = 3;
51
+
43
52
  constructor(options = {}) {
44
53
  const config = options.config || loadConfig();
45
54
  this.config = config;
@@ -50,7 +59,10 @@ class HostConnector {
50
59
  // Optional; a relay without a password accepts any workstation.
51
60
  this.relayPassword = options.relayPassword ?? process.env.RELAY_PASSWORD ?? '';
52
61
  this.socketPath = options.socketPath || resolveSocketPath(config.herdr.socketPath);
53
- this.herdrCommand = options.herdrCommand || resolveHerdrCommand();
62
+ // Where to look for Herdr when the command has to be resolved again; the
63
+ // real environment unless a caller narrows it.
64
+ this.herdrLookup = options.herdrLookup || {};
65
+ this.herdrCommand = options.herdrCommand || resolveHerdrCommand(this.herdrLookup);
54
66
  this.herdrArgs = options.herdrArgs || config.herdr.args;
55
67
  this.cwd = options.cwd || config.herdr.cwd;
56
68
  /**
@@ -65,6 +77,7 @@ class HostConnector {
65
77
  : resolveHostPalette();
66
78
  this.ws = null;
67
79
  this.sessions = new Map();
80
+ this.fastFailures = 0;
68
81
  this.reconnectTimer = null;
69
82
  this.heartbeatTimer = null;
70
83
  this.reconnectAttempts = 0;
@@ -77,6 +90,7 @@ class HostConnector {
77
90
  || process.env.HERDR_REMOTE_HOST_LOCK
78
91
  || path.join(stateDir(), 'host-connector.lock');
79
92
  this.lockFd = null;
93
+ try { cleanPastedDir(); } catch {}
80
94
  }
81
95
 
82
96
  acquireLock() {
@@ -268,12 +282,42 @@ class HostConnector {
268
282
  if (message.type === 'session_start') this.startSession(message);
269
283
  else if (message.type === 'session_stop') this.stopSession(message.clientId || message.streamId);
270
284
  else if (message.type === 'resize') this.resizeSession(message);
285
+ else if (message.type === 'paste_file') this.handlePasteFile(message);
286
+ }
287
+
288
+ /**
289
+ * Confirm Herdr is really there before the name reaches `pty.spawn`.
290
+ *
291
+ * node-pty resolves the command with `execvp(3)` inside the forked child, so
292
+ * a missing binary is not a spawn error anyone can catch: the child writes
293
+ * "execvp(3) failed.: No such file or directory" into the PTY and exits. The
294
+ * `session_exit` that follows makes the relay drop the browser's socket, the
295
+ * browser reconnects into the same broken start, and that is the reconnect
296
+ * loop of issue #1. Refusing to start keeps the socket up and puts the actual
297
+ * reason in front of the user.
298
+ *
299
+ * Re-resolved per session rather than cached from the constructor: a service
300
+ * that started before Herdr was installed should pick it up without a restart.
301
+ */
302
+ ensureHerdrCommand() {
303
+ const resolved = verifyHerdrCommand(this.herdrCommand, this.herdrLookup);
304
+ if (resolved.found) {
305
+ this.herdrCommand = resolved.command;
306
+ return null;
307
+ }
308
+ return herdrNotFoundMessage(this.herdrLookup);
271
309
  }
272
310
 
273
311
  startSession(message) {
274
312
  const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
275
313
  if (!streamId) return;
276
314
  this.stopSession(streamId);
315
+ const missingHerdr = this.ensureHerdrCommand();
316
+ if (missingHerdr) {
317
+ process.stderr.write(`herdr-remote host connector: ${missingHerdr}\n`);
318
+ sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_not_found', message: missingHerdr });
319
+ return;
320
+ }
277
321
  const socketInfo = inspectSocket(this.socketPath);
278
322
  if (!socketInfo.ok) {
279
323
  sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_socket_unavailable', message: socketInfo.reason });
@@ -291,6 +335,7 @@ class HostConnector {
291
335
  cols: Number(message.cols) || PtySession.DEFAULT_COLS,
292
336
  rows: Number(message.rows) || PtySession.DEFAULT_ROWS,
293
337
  createdAt: new Date().toISOString(),
338
+ startedAtMs: Date.now(),
294
339
  clientId: streamId,
295
340
  };
296
341
  try {
@@ -304,7 +349,7 @@ class HostConnector {
304
349
  onExit: ({ exitCode }) => {
305
350
  if (this.sessions.get(streamId) !== session) return;
306
351
  this.sessions.delete(streamId);
307
- sendJson(this.ws, { type: 'session_exit', clientId: streamId, code: exitCode });
352
+ this.reportSessionExit(session, exitCode);
308
353
  this.sendHeartbeat();
309
354
  },
310
355
  });
@@ -318,6 +363,32 @@ class HostConnector {
318
363
  this.sendHeartbeat();
319
364
  }
320
365
 
366
+ /**
367
+ * Tell the relay how a session ended.
368
+ *
369
+ * `session_exit` is the honest answer for a session that ran, and the relay
370
+ * closes the browser's socket on it so the window can start a fresh one. That
371
+ * is also what turns a start that keeps failing into a reconnect loop: exit,
372
+ * close, reconnect, exit. Once a few starts in a row have died immediately
373
+ * with a non-zero code the failure is systemic, not a session ending, so it is
374
+ * reported as an error — which the relay forwards without dropping the socket,
375
+ * leaving the user with a message instead of a spinner.
376
+ */
377
+ reportSessionExit(session, exitCode) {
378
+ const streamId = session.id;
379
+ const lifetimeMs = Date.now() - session.startedAtMs;
380
+ const failedFast = exitCode !== 0 && lifetimeMs < HostConnector.FAST_FAILURE_MS;
381
+ this.fastFailures = failedFast ? this.fastFailures + 1 : 0;
382
+ if (this.fastFailures < HostConnector.FAST_FAILURE_LIMIT) {
383
+ sendJson(this.ws, { type: 'session_exit', clientId: streamId, code: exitCode });
384
+ return;
385
+ }
386
+ const message = `"${this.herdrCommand}" exited immediately with code ${exitCode} on ${this.fastFailures} attempts in a row. `
387
+ + 'Check that it runs from a terminal, and see the host connector log for what it printed.';
388
+ process.stderr.write(`herdr-remote host connector: ${message}\n`);
389
+ sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_start_failed', message });
390
+ }
391
+
321
392
  stopSession(streamId) {
322
393
  const session = this.sessions.get(streamId);
323
394
  if (!session) return;
@@ -351,6 +422,28 @@ class HostConnector {
351
422
  session.pty.resize(session.cols, session.rows);
352
423
  }
353
424
 
425
+ handlePasteFile(message) {
426
+ const streamId = message.clientId || message.streamId;
427
+ try {
428
+ const savedPath = savePastedFile({
429
+ mime: message.mime,
430
+ dataBase64: message.dataBase64,
431
+ });
432
+ sendJson(this.ws, {
433
+ type: 'paste_file_ready',
434
+ clientId: streamId,
435
+ path: savedPath,
436
+ });
437
+ } catch (error) {
438
+ sendJson(this.ws, {
439
+ type: 'error',
440
+ clientId: streamId,
441
+ code: 'paste_file_write_failed',
442
+ message: error.message || 'Failed to save pasted file',
443
+ });
444
+ }
445
+ }
446
+
354
447
  destroySessions() {
355
448
  for (const session of this.sessions.values()) session.pty.kill();
356
449
  this.sessions.clear();
package/src/i18n/en.js CHANGED
@@ -154,7 +154,7 @@ module.exports = {
154
154
  'herdr.registerDone': 'Registered: {path}',
155
155
  'herdr.unregisterDone': 'Plugin unregistered.',
156
156
  'herdr.registerFailed': 'Registration failed: {message}',
157
- 'herdr.cliMissing': 'Command herdr not found in PATH.',
157
+ 'herdr.cliMissing': 'Command herdr not found. Set HERDR_BIN_PATH to its full path.',
158
158
 
159
159
  'about.title': 'Language & about',
160
160
  'about.language': 'Interface language',
package/src/i18n/zh.js CHANGED
@@ -153,7 +153,7 @@ module.exports = {
153
153
  'herdr.registerDone': '已注册:{path}',
154
154
  'herdr.unregisterDone': '已取消注册。',
155
155
  'herdr.registerFailed': '注册失败:{message}',
156
- 'herdr.cliMissing': 'PATH 中未找到 herdr 命令。',
156
+ 'herdr.cliMissing': '未找到 herdr 命令,可将 HERDR_BIN_PATH 设为其完整路径。',
157
157
 
158
158
  'about.title': '语言与关于',
159
159
  'about.language': '界面语言',
package/src/keepalive.js CHANGED
@@ -15,6 +15,7 @@ const { spawn, spawnSync } = require('node:child_process');
15
15
  const { PACKAGE_ROOT, loadConfig, stateDir } = require('./config');
16
16
  const { ensureDir, readJson, writeJsonAtomic } = require('./state');
17
17
  const { logPath, pidAlive } = require('./service');
18
+ const { findHerdrCommand } = require('./herdr-command');
18
19
 
19
20
  const SYSTEMD_UNIT_NAME = 'herdr-remote.service';
20
21
  const LAUNCHD_LABEL = 'dev.herdr.remote';
@@ -71,13 +72,65 @@ function detectManager(preference = 'auto') {
71
72
  return 'supervisor';
72
73
  }
73
74
 
75
+ // ---------------------------------------------------------------------------
76
+ // The environment a supervised copy needs
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * `PATH` for the unit: the one that is resolving commands right now, plus the
81
+ * directory Herdr was found in.
82
+ *
83
+ * A service manager does not inherit the shell's environment. `systemd --user`
84
+ * hands a unit something close to `/usr/local/bin:/usr/bin:/bin` and `launchd`
85
+ * is no more generous, so a copy that works from a terminal loses `~/.local/bin`
86
+ * — and with it `herdr` — the moment it is installed as a service. Freezing the
87
+ * installing shell's `PATH` into the unit keeps the supervised copy able to find
88
+ * Herdr, and Herdr able to find the tools it spawns in turn.
89
+ */
90
+ function servicePath({ env = process.env, herdrCommand = null } = {}) {
91
+ const entries = String(env.PATH || '')
92
+ .split(path.delimiter)
93
+ .map((entry) => entry.trim())
94
+ .filter(Boolean);
95
+ // The override already pins the exact binary; this is only so the session
96
+ // itself can still reach it by name.
97
+ if (herdrCommand) entries.push(path.dirname(herdrCommand));
98
+ const seen = new Set();
99
+ const unique = entries.filter((entry) => (seen.has(entry) ? false : seen.add(entry)));
100
+ return unique.join(path.delimiter);
101
+ }
102
+
103
+ /**
104
+ * What has to be written into the unit file for a supervised start to behave
105
+ * like the one the user just ran by hand.
106
+ */
107
+ function serviceEnvironment({ env = process.env, home = os.homedir(), directories } = {}) {
108
+ const environment = {};
109
+ const herdr = findHerdrCommand({ env, home, ...(directories ? { directories } : {}) });
110
+ if (herdr.found) environment.HERDR_BIN_PATH = herdr.command;
111
+ const searchPath = servicePath({ env, herdrCommand: herdr.found ? herdr.command : null });
112
+ if (searchPath) environment.PATH = searchPath;
113
+ return environment;
114
+ }
115
+
74
116
  // ---------------------------------------------------------------------------
75
117
  // Unit file rendering (pure, so it can be asserted in tests)
76
118
  // ---------------------------------------------------------------------------
77
119
 
120
+ /**
121
+ * One `KEY=value` per directive. An unquoted systemd value ends at the first
122
+ * space, and `%` starts a specifier, so a `PATH` with either in it would be
123
+ * silently truncated or rewritten.
124
+ */
125
+ function systemdEnvironmentLine(key, value) {
126
+ const text = String(value).replace(/[\r\n]+/g, ' ').replace(/%/g, '%%');
127
+ if (/^[\w@+=:,./-]*$/.test(text)) return `Environment=${key}=${text}`;
128
+ return `Environment="${key}=${text.replace(/([\\"])/g, '\\$1')}"`;
129
+ }
130
+
78
131
  function renderSystemdUnit({ nodePath = process.execPath, entryPoint = cliEntryPoint(), environment = {} } = {}) {
79
132
  const environmentLines = Object.entries(environment)
80
- .map(([key, value]) => `Environment=${key}=${value}`)
133
+ .map(([key, value]) => systemdEnvironmentLine(key, value))
81
134
  .join('\n');
82
135
  return `[Unit]
83
136
  Description=Herdr Remote (relay and host connector)
@@ -179,7 +232,7 @@ function systemdStatus() {
179
232
  function systemdInstall() {
180
233
  const unitPath = systemdUnitPath();
181
234
  ensureDir(path.dirname(unitPath));
182
- fs.writeFileSync(unitPath, renderSystemdUnit(), { mode: 0o644 });
235
+ fs.writeFileSync(unitPath, renderSystemdUnit({ environment: serviceEnvironment() }), { mode: 0o644 });
183
236
  const reload = systemctl(['daemon-reload']);
184
237
  if (reload.status !== 0) {
185
238
  throw new Error(`systemctl --user daemon-reload failed: ${String(reload.stderr || '').trim()}`);
@@ -227,7 +280,7 @@ function launchdInstall() {
227
280
  const plistPath = launchdPlistPath();
228
281
  ensureDir(path.dirname(plistPath));
229
282
  ensureDir(stateDir());
230
- fs.writeFileSync(plistPath, renderLaunchdPlist(), { mode: 0o644 });
283
+ fs.writeFileSync(plistPath, renderLaunchdPlist({ environment: serviceEnvironment() }), { mode: 0o644 });
231
284
  // bootout first so a re-install picks up the rewritten plist.
232
285
  spawnSync('launchctl', ['bootout', `${launchdDomainTarget()}/${LAUNCHD_LABEL}`], { stdio: 'ignore' });
233
286
  const result = spawnSync('launchctl', ['bootstrap', launchdDomainTarget(), plistPath], { encoding: 'utf8' });
@@ -275,7 +328,7 @@ function fallbackInstall() {
275
328
  try {
276
329
  const child = spawn(process.execPath, [cliEntryPoint(), 'run', '--daemon'], {
277
330
  cwd: PACKAGE_ROOT,
278
- env: { ...process.env, HERDR_REMOTE_SERVICE: '1' },
331
+ env: { ...process.env, HERDR_REMOTE_SERVICE: '1', ...serviceEnvironment() },
279
332
  detached: true,
280
333
  stdio: ['ignore', logFd, logFd],
281
334
  });
@@ -394,6 +447,9 @@ module.exports = {
394
447
  renderSystemdUnit,
395
448
  renderLaunchdPlist,
396
449
  escapeXml,
450
+ servicePath,
451
+ serviceEnvironment,
452
+ systemdEnvironmentLine,
397
453
  status,
398
454
  install,
399
455
  uninstall,
@@ -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
+ };
package/src/service.js CHANGED
@@ -26,7 +26,7 @@ const {
26
26
  } = require('./terminal-palette');
27
27
  const { resolveSocketPath } = require('./socket-discovery');
28
28
  const { preferredLanAddress } = require('./net-interfaces');
29
- const { resolveHerdrCommand } = require('./herdr-command');
29
+ const { findHerdrCommand, resolveHerdrCommand } = require('./herdr-command');
30
30
 
31
31
  const RUNTIME_VERSION = 2;
32
32
 
@@ -415,6 +415,9 @@ async function statusServices() {
415
415
  health = { ok: false, message: error.message };
416
416
  }
417
417
  const socketPath = resolveSocketPath(config.herdr.socketPath);
418
+ // Where Herdr was found, and whether it was found at all: under a service
419
+ // manager this is the first thing to check when sessions will not start.
420
+ const herdr = findHerdrCommand();
418
421
  return {
419
422
  ok: true,
420
423
  mode: config.relay.mode,
@@ -433,6 +436,9 @@ async function statusServices() {
433
436
  hostId: state.hostId || null,
434
437
  socketPath,
435
438
  socketExists: Boolean(socketPath) && fs.existsSync(socketPath),
439
+ herdrCommand: herdr.command,
440
+ herdrCommandFound: herdr.found,
441
+ herdrCommandSource: herdr.source,
436
442
  },
437
443
  publicUrl: resolvePublicUrl(config, lanAddress),
438
444
  startedAt: state.startedAt || null,