relay-companion 0.1.417 → 0.1.418

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/README.md CHANGED
@@ -16,6 +16,6 @@ npx --yes relay-companion@<EXACT_VERSION> setup
16
16
 
17
17
  Setup installs and opens the signed-out Relay pill, registers the local Relay MCP launcher for Claude Code and Codex, and registers the always-on task daemon with macOS launchd, Windows Task Scheduler, or owner-scoped Linux systemd services. New setup never asks the agent for a pairing code.
18
18
 
19
- Relay supports macOS, Windows, and 64-bit Linux on x64 and arm64. Linux targets current glibc distributions with a graphical X11 or Wayland session and a systemd user manager. Setup uses XDG application/autostart entries and does not need root access. Claude Code and Codex CLI are supported Linux hosts; Claude Desktop is not available on Linux. Alpine/musl, 32-bit Linux, headless-only sessions, and Linux systems without a systemd user session are outside the supported matrix.
19
+ Relay supports macOS, Windows, and 64-bit Linux on x64 and arm64. Linux targets current glibc distributions with a graphical X11 session, or a Wayland session with XWayland, and a systemd user manager. Setup uses owner-scoped systemd services and XDG application/autostart entries. Where unprivileged user namespaces are restricted, including Ubuntu 24.04's default AppArmor policy, setup asks once for administrator approval to install the exact, content-addressed Chromium sandbox helper in a root-owned location; Relay never disables Electron's sandbox. A later update that needs a new helper waits for `relay update` to be run in a desktop terminal. Claude Code and Codex CLI are supported Linux hosts; Claude Desktop is not available on Linux. Alpine/musl, 32-bit Linux, headless-only sessions, Linux without XWayland, and systems without a systemd user session are outside the supported matrix.
20
20
 
21
- On Linux, use `systemctl --user status work.relay.companion.service work.relay.companion.pill.service` for service health and `journalctl --user -u work.relay.companion.service -u work.relay.companion.pill.service` for logs.
21
+ Linux setup installs a durable command at `~/.local/bin/relay`; open a new login session if that directory was not already on `PATH`. Start diagnosis with `relay doctor`. Live logs are `~/.relay/daemon.log` and `~/.relay/pill.log`; use `systemctl --user status work.relay.companion.service work.relay.companion.pill.service` for service health and `journalctl --user -u work.relay.companion.service -u work.relay.companion.pill.service` for lifecycle history.
package/bin/relay.js CHANGED
@@ -33,22 +33,34 @@ import {
33
33
  } from "../src/cli-trampoline.js";
34
34
  import {
35
35
  hookInstallNotices,
36
+ linuxDesktopPaths,
37
+ prepareLinuxElectronSandbox,
38
+ persistentNodePath,
36
39
  purgeLocalState,
40
+ relayBinPath,
37
41
  windowsAutostartTaskStatus,
38
42
  repairDesktopSurfaces,
39
43
  repairExistingAgentHooks,
40
44
  repairExistingAgentRegistrations,
41
45
  accountRestartLines,
46
+ desktopExecQuote,
42
47
  restartRelayServices,
43
48
  runSetupInstall,
44
49
  runUninstall,
50
+ startLinuxDesktopServices,
51
+ systemdExecQuote,
45
52
  windowsStartMenuShortcutMissing,
46
53
  windowsStartMenuShortcutPath,
47
54
  } from "../src/install.js";
55
+ import { readAutostartDaemonRoot } from "../src/autostart-registration.js";
48
56
  import { companionPackageRoot, readMigrationFailure, readRecoveryFailure, runUpdateOnce } from "../src/auto-update.js";
49
- import { canonicalRuntimeLayout, readCanonicalRuntimeState } from "../src/canonical-runtime.js";
57
+ import {
58
+ canonicalRuntimeLayout,
59
+ readCanonicalRuntimeState,
60
+ reconcileCanonicalRuntimeNode,
61
+ } from "../src/canonical-runtime.js";
50
62
  import { listUpdateWorkerJobs } from "../src/canonical-updater.js";
51
- import { pillStatusPath, waitForPillReady } from "../src/pill-control.js";
63
+ import { pillStatusPath, readPillStatus, waitForPillReady } from "../src/pill-control.js";
52
64
  import { liveToolRequirement, requiredLiveHosts, shouldRequireLiveTools } from "../src/setup-activation.js";
53
65
  import { openRelay, openTask } from "../src/materializer.js";
54
66
  import { runClaudeHook } from "../src/claude-hook.js";
@@ -120,6 +132,14 @@ function companionVersion() {
120
132
  }
121
133
  }
122
134
 
135
+ function companionDistribution() {
136
+ try {
137
+ return String(createRequire(import.meta.url)("../package.json").relayDistribution || "");
138
+ } catch {
139
+ return "";
140
+ }
141
+ }
142
+
123
143
  async function prompt(question, fallback = "") {
124
144
  const rl = readline.createInterface({ input: stdin, output: stdout });
125
145
  const answer = (await rl.question(question)).trim();
@@ -394,15 +414,25 @@ async function cmdSetup(flags) {
394
414
 
395
415
  /** Install the tools + daemon on a device that is already paired. */
396
416
  async function cmdInstall(flags = {}) {
397
- await applyInstall({
417
+ const install = await applyInstall({
398
418
  requireLiveTools: shouldRequireLiveTools(),
399
419
  requiredHosts: requiredLiveHosts(),
400
420
  claim: Boolean(flags.claim),
401
421
  });
422
+ // `relay setup` already propagates a Linux systemd/pill failure. The shorter
423
+ // `relay install` path must keep the same contract: printing recovery advice
424
+ // and then exiting zero makes automation (and a person skimming the last line)
425
+ // believe a machine is ready when neither background process will survive the
426
+ // terminal. Preserve the detailed messages from applyInstall, but fail the
427
+ // command so the incomplete lifecycle cannot be mistaken for success.
428
+ if (install?.lifecycleFailed) process.exitCode = 1;
402
429
  }
403
430
 
404
431
  function cmdRepairDesktop(flags = {}) {
405
432
  const reload = !flags["no-restart"];
433
+ const env = process.platform === "linux"
434
+ ? { ...process.env, RELAY_ALLOW_SANDBOX_AUTHORIZATION: "1" }
435
+ : process.env;
406
436
  // Repair is deliberately local and non-destructive. It may rewrite Relay's
407
437
  // launchers and Relay-owned hook registrations, but it never rewrites account
408
438
  // config, deletes credentials/E2EE state, clears messages/outboxes/preferences,
@@ -415,7 +445,7 @@ function cmdRepairDesktop(flags = {}) {
415
445
  if (!hookRepair.ok) {
416
446
  throw new Error(`Could not repair Relay agent hooks (${hookRepair.reason || "migration failed"}).`);
417
447
  }
418
- const repaired = repairDesktopSurfaces({ reload, claim: Boolean(flags.claim) });
448
+ const repaired = repairDesktopSurfaces({ reload, claim: Boolean(flags.claim), env });
419
449
  if (!repaired.ok) {
420
450
  const failures = [
421
451
  !repaired.daemon?.ok && `daemon: ${repaired.daemon?.message || repaired.daemon?.reason || "install failed"}`,
@@ -436,9 +466,11 @@ function cmdRepairRuntime(flags = {}) {
436
466
  // older legacy runtime whose own CLI predates `repair-runtime`.
437
467
  const targetBin = flags["target-bin"] ? path.resolve(String(flags["target-bin"])) : undefined;
438
468
  const targetNode = flags["target-node"] ? String(flags["target-node"]) : undefined;
469
+ const runtimeBin = targetBin || relayBinPath();
470
+ const runtimeNode = persistentNodePath(targetNode || process.execPath);
439
471
  const target = {
440
- ...(targetBin ? { bin: targetBin } : {}),
441
- ...(targetNode ? { node: targetNode } : {}),
472
+ bin: runtimeBin,
473
+ node: runtimeNode,
442
474
  };
443
475
  const registrations = repairExistingAgentRegistrations(target);
444
476
  if (!registrations.ok) {
@@ -448,7 +480,17 @@ function cmdRepairRuntime(flags = {}) {
448
480
  if (!repaired.ok) {
449
481
  throw new Error("Could not repair Relay runtime services.");
450
482
  }
451
- return { ok: true, registrations, ...repaired };
483
+ const pointer = reconcileCanonicalRuntimeNode({ node: runtimeNode });
484
+ if (!pointer.ok) {
485
+ throw new Error(`Could not reconcile Relay's active runtime pointer (${pointer.reason || "verification failed"}).`);
486
+ }
487
+ const pointerText = pointer.changed
488
+ ? " The active runtime pointer was updated."
489
+ : pointer.reason === "already-current"
490
+ ? " The active runtime pointer was already current."
491
+ : "";
492
+ console.log(`Relay runtime repaired with its durable Node.${pointerText}`);
493
+ return { ok: true, registrations, pointer, ...repaired };
452
494
  }
453
495
 
454
496
  /**
@@ -513,6 +555,9 @@ async function cmdUninstall(flags = {}) {
513
555
  console.log("The pairing is gone, so this machine is forgotten, but some files above could not be deleted.");
514
556
  console.log("They are inert leftovers — remove them by hand when whatever holds them exits.");
515
557
  }
558
+ if (process.platform === "linux") {
559
+ console.log("A root-owned Chromium sandbox helper may remain under /usr/local/lib/relay because it can be shared by other users on this Linux device.");
560
+ }
516
561
  console.log("This machine has forgotten Relay. To remove the package itself, run:");
517
562
  console.log(" npm uninstall -g relay-companion");
518
563
  }
@@ -626,6 +671,46 @@ async function cmdPill(flags = {}, positional = []) {
626
671
  if (typeof electronPath !== "string") {
627
672
  throw new Error("Could not resolve the Electron binary. Run `npm install` in packages/companion.");
628
673
  }
674
+ const pillEnv = { ...process.env };
675
+ if (process.platform === "linux") {
676
+ pillEnv.RELAY_ALLOW_SANDBOX_AUTHORIZATION = "1";
677
+ const repaired = repairDesktopSurfaces({ reload: false, env: pillEnv });
678
+ if (!repaired.ok) {
679
+ const failure = repaired.pill?.detail || repaired.pill?.reason || repaired.daemon?.detail || repaired.daemon?.reason;
680
+ throw new Error(failure || "Relay could not prepare its Linux desktop services.");
681
+ }
682
+ const expectedRoot = path.resolve(here, "..");
683
+ const beforePidResult = spawnSync("systemctl", ["--user", "show", "--property=MainPID", "--value", "work.relay.companion.pill.service"], {
684
+ encoding: "utf8",
685
+ timeout: 5_000,
686
+ });
687
+ const beforePid = Number(String(beforePidResult.stdout || "").trim());
688
+ const beforeStatus = readPillStatus();
689
+ const exactPillAlreadyOwned = !beforePidResult.error && beforePidResult.status === 0 && beforePid > 0 &&
690
+ beforeStatus?.pid === beforePid && path.resolve(String(beforeStatus.packageRoot || "")) === expectedRoot;
691
+ const started = startLinuxDesktopServices({ env: pillEnv, restartPill: beforePid > 0 && !exactPillAlreadyOwned });
692
+ if (!started.ok) throw new Error(started.detail || `Relay could not start ${started.unit || "its Linux services"}.`);
693
+
694
+ // The systemd unit must own the singleton before the short-lived second
695
+ // Electron instance delivers a reopen/deep-link signal. Otherwise a manual
696
+ // app launch can become an unsupervised pill and strand future restarts.
697
+ const deadline = Date.now() + 12_000;
698
+ let supervised = false;
699
+ while (Date.now() <= deadline) {
700
+ const mainPid = spawnSync("systemctl", ["--user", "show", "--property=MainPID", "--value", "work.relay.companion.pill.service"], {
701
+ encoding: "utf8",
702
+ timeout: 5_000,
703
+ });
704
+ const status = readPillStatus();
705
+ const pid = Number(String(mainPid.stdout || "").trim());
706
+ if (!mainPid.error && mainPid.status === 0 && pid > 0 && status?.ready === true && status.pid === pid && path.resolve(String(status.packageRoot || "")) === expectedRoot) {
707
+ supervised = true;
708
+ break;
709
+ }
710
+ await new Promise((resolve) => setTimeout(resolve, 100));
711
+ }
712
+ if (!supervised) throw new Error("Relay's registered Linux pill service did not become ready. Run `relay doctor` for details.");
713
+ }
629
714
  const reopenNonce = `cli-${process.pid}-${randomUUID()}`;
630
715
  const deepLinks = positional
631
716
  .map((value) => String(value || ""))
@@ -637,7 +722,7 @@ async function cmdPill(flags = {}, positional = []) {
637
722
  {
638
723
  detached: true,
639
724
  stdio: "ignore",
640
- env: { ...process.env },
725
+ env: pillEnv,
641
726
  },
642
727
  );
643
728
  child.on("error", (error) => {
@@ -819,6 +904,122 @@ async function cmdDoctor(flags = {}) {
819
904
  console.log(" Start Menu shortcut: ok (Start → \"relay\" opens the pill)");
820
905
  }
821
906
  }
907
+ if (process.platform === "linux") {
908
+ const desktopPaths = linuxDesktopPaths();
909
+ const activeRoot = path.resolve(companionPackageRoot());
910
+ const activeUnitRoot = systemdExecQuote(activeRoot).slice(1, -1);
911
+ const activeDesktopRoot = desktopExecQuote(activeRoot).slice(1, -1);
912
+ const units = [
913
+ { label: "background service", unit: "work.relay.companion.service", expectedPath: desktopPaths.daemonUnitPath },
914
+ { label: "Relay pill", unit: "work.relay.companion.pill.service", expectedPath: desktopPaths.pillUnitPath },
915
+ ];
916
+ console.log(" Linux services:");
917
+ for (const { label, unit, expectedPath } of units) {
918
+ const probe = spawnSync(
919
+ "systemctl",
920
+ ["--user", "show", unit, "--property=LoadState,UnitFileState,ActiveState,SubState,MainPID,FragmentPath", "--no-pager"],
921
+ { encoding: "utf8", timeout: 10_000 },
922
+ );
923
+ const fields = Object.fromEntries(
924
+ String(probe.stdout || "")
925
+ .split(/\r?\n/)
926
+ .map((line) => line.split("="))
927
+ .filter(([key, ...value]) => key && value.length)
928
+ .map(([key, ...value]) => [key, value.join("=")]),
929
+ );
930
+ if (probe.error || (!fields.LoadState && probe.status !== 0)) {
931
+ const detail = probe.error?.message || String(probe.stderr || "systemd user manager unavailable").trim();
932
+ console.log(` ${label}: unable to inspect (${detail})`);
933
+ continue;
934
+ }
935
+ if (fields.LoadState !== "loaded") {
936
+ console.log(` ${label}: NOT INSTALLED (${unit})`);
937
+ console.log(" fix with: relay repair-installation");
938
+ continue;
939
+ }
940
+ const runtime = [fields.ActiveState, fields.SubState].filter(Boolean).join("/") || "unknown";
941
+ const registration = fields.UnitFileState ? `, ${fields.UnitFileState}` : "";
942
+ const mainPid = Number.parseInt(String(fields.MainPID || "0"), 10);
943
+ console.log(` ${label}: ${runtime}${registration}, pid ${mainPid || "none"} (${unit})`);
944
+ let fragmentMatches = false;
945
+ try { fragmentMatches = fsSync.realpathSync(fields.FragmentPath) === fsSync.realpathSync(expectedPath); }
946
+ catch { fragmentMatches = path.resolve(String(fields.FragmentPath || "")) === path.resolve(expectedPath); }
947
+ if (!fragmentMatches) {
948
+ console.log(` WRONG UNIT FILE: systemd resolved ${fields.FragmentPath || "nothing"}`);
949
+ console.log(` expected: ${expectedPath}`);
950
+ console.log(" fix with: relay repair-installation");
951
+ }
952
+ let unitNamesActiveRoot = false;
953
+ try { unitNamesActiveRoot = fsSync.readFileSync(expectedPath, "utf8").includes(activeUnitRoot); } catch {}
954
+ if (!unitNamesActiveRoot) {
955
+ console.log(` STALE COMMAND: ${expectedPath} does not name the active Relay runtime`);
956
+ console.log(" fix with: relay repair-installation");
957
+ }
958
+ if (fields.ActiveState !== "active" || !Number.isInteger(mainPid) || mainPid <= 0) {
959
+ console.log(` inspect with: systemctl --user status ${unit}`);
960
+ console.log(" fix with: relay repair-installation");
961
+ }
962
+ }
963
+ const registered = readAutostartDaemonRoot({ platform: "linux" });
964
+ let registrationMatches = false;
965
+ try { registrationMatches = fsSync.realpathSync(registered?.root) === fsSync.realpathSync(activeRoot); }
966
+ catch { registrationMatches = Boolean(registered?.root) && path.resolve(registered.root) === activeRoot; }
967
+ if (!registrationMatches) {
968
+ console.log(` Linux runtime registration: WRONG (${registered?.root || "unreadable"}; active ${activeRoot})`);
969
+ console.log(" fix with: relay repair-installation");
970
+ } else {
971
+ console.log(` Linux runtime registration: ok (${activeRoot})`);
972
+ }
973
+ console.log(" Linux desktop integration:");
974
+ for (const [label, file] of [
975
+ ["app launcher", desktopPaths.applicationPath],
976
+ ["login autostart", desktopPaths.autostartPath],
977
+ ]) {
978
+ const exists = fsSync.existsSync(file);
979
+ let stale = false;
980
+ if (exists && label === "app launcher") {
981
+ try { stale = !fsSync.readFileSync(file, "utf8").includes(activeDesktopRoot); }
982
+ catch { stale = true; }
983
+ }
984
+ console.log(` ${label}: ${!exists ? `MISSING (${file})` : stale ? `STALE (${file})` : "ok"}`);
985
+ if (!exists || stale) console.log(" fix with: relay repair-installation");
986
+ }
987
+ const mime = spawnSync("xdg-mime", ["query", "default", "x-scheme-handler/relay"], {
988
+ encoding: "utf8",
989
+ timeout: 10_000,
990
+ });
991
+ const mimeHandler = String(mime.stdout || "").trim();
992
+ if (!mime.error && mime.status === 0 && mimeHandler === "work.relay.companion.desktop") {
993
+ console.log(" relay: links: ok");
994
+ } else {
995
+ console.log(` relay: links: WRONG (${mimeHandler || mime.error?.message || "no handler"})`);
996
+ console.log(" fix with: relay repair-installation");
997
+ }
998
+ try {
999
+ const electronPath = createRequire(import.meta.url)("electron");
1000
+ const sandboxEnv = {};
1001
+ const sandbox = prepareLinuxElectronSandbox({
1002
+ platform: "linux",
1003
+ electronPath,
1004
+ env: sandboxEnv,
1005
+ allowAuthorization: false,
1006
+ });
1007
+ if (sandbox.ok) {
1008
+ console.log(` Electron sandbox: ${sandbox.discovered ? `verified (${sandbox.destination})` : "kernel user namespace available"}`);
1009
+ } else {
1010
+ console.log(` Electron sandbox: NEEDS ATTENTION (${sandbox.detail || sandbox.reason})`);
1011
+ console.log(" fix with: relay update");
1012
+ }
1013
+ } catch (error) {
1014
+ console.log(` Electron sandbox: unable to inspect (${error?.message || error})`);
1015
+ console.log(" fix with: relay repair-installation");
1016
+ }
1017
+ const relayLogDir = path.join(os.homedir(), ".relay");
1018
+ console.log(" Linux logs:");
1019
+ console.log(` background service: ${path.join(relayLogDir, "daemon.log")}`);
1020
+ console.log(` Relay pill: ${path.join(relayLogDir, "pill.log")}`);
1021
+ console.log(" service history: journalctl --user -u work.relay.companion.service -u work.relay.companion.pill.service");
1022
+ }
822
1023
  if (flags["requeue-unseen"] && prefs.pillHidden === true) {
823
1024
  console.log("Refusing --requeue-unseen while \"Show Relay automatically\" is off: nothing can present, so it would only grow the queue.");
824
1025
  console.log("Turn that setting on in Relay's Settings tab first.");
@@ -830,6 +1031,18 @@ async function cmdDoctor(flags = {}) {
830
1031
  const uid = typeof process.getuid === "function" ? process.getuid() : 501;
831
1032
  spawnSync("launchctl", ["kickstart", "-k", `gui/${uid}/work.relay.companion.pill`], { stdio: "ignore" });
832
1033
  console.log("Pill restarted; the queue will replay them sequentially when you are present.");
1034
+ } else if (flags["restart-pill"] && process.platform === "linux") {
1035
+ const restarted = await restartRelayServices({ services: ["pill"] });
1036
+ if (restarted.pill === "restarted") {
1037
+ console.log("Pill restarted; the queue will replay them sequentially when you are present.");
1038
+ } else if (restarted.pill === "not_installed") {
1039
+ console.log("The Relay pill service is not installed; run `relay repair-installation`.");
1040
+ } else {
1041
+ const detail = restarted.detail?.pill ? ` (${restarted.detail.pill})` : "";
1042
+ console.log(`The Relay pill did not restart${detail}; run \`relay repair-installation\` or restart it with systemctl.`);
1043
+ }
1044
+ } else if (process.platform === "linux") {
1045
+ console.log("Restart the pill to pick this up: systemctl --user restart work.relay.companion.pill.service");
833
1046
  } else {
834
1047
  console.log("Restart the pill to pick this up: launchctl kickstart -k gui/$UID/work.relay.companion.pill");
835
1048
  }
@@ -952,6 +1165,25 @@ let trampolineArrival = null;
952
1165
 
953
1166
  async function main() {
954
1167
  const [command, ...rest] = process.argv.slice(2);
1168
+ // The one-time full migration bridge still publishes bin/relay.js so older
1169
+ // fleets can reach it. A human's first `setup`, however, must exercise the
1170
+ // same signed canonical bootstrap and release canary as the later thin
1171
+ // installer. Candidate activation sets this marker before calling setup back.
1172
+ if (
1173
+ command === "setup" &&
1174
+ companionDistribution() === "bridge-runtime" &&
1175
+ process.env.RELAY_BOOTSTRAP_ACTIVATED !== "1"
1176
+ ) {
1177
+ const bootstrap = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../bootstrap/relay-setup.cjs");
1178
+ const result = spawnSync(process.execPath, [bootstrap, "setup", ...rest], {
1179
+ stdio: "inherit",
1180
+ windowsHide: true,
1181
+ env: process.env,
1182
+ });
1183
+ if (result.error) throw result.error;
1184
+ process.exitCode = Number.isInteger(result.status) ? result.status : 1;
1185
+ return;
1186
+ }
955
1187
  // The typed `relay` command is the npm global shim and would otherwise stay
956
1188
  // at install-time version forever while the canonical runtime moves on
957
1189
  // underneath it. Hand human-facing commands to the active release; the
@@ -1062,7 +1294,7 @@ async function main() {
1062
1294
  " relay setup --code CODE Legacy migration only: pair by code, then install",
1063
1295
  " relay install Add Relay to your agents",
1064
1296
  " relay uninstall Remove Relay from your agents and stop the daemon (keeps the pairing)",
1065
- " relay uninstall --purge Also revoke this device and delete all local Relay state, as if never installed",
1297
+ " relay uninstall --purge Also revoke this device and delete all owner-scoped Relay state",
1066
1298
  " relay repair-installation [--no-restart] Non-destructively repair Relay.app and services; preserves account, encryption, messages, outbox, and preferences",
1067
1299
  " relay repair-desktop [--no-restart] Compatibility alias for repair-installation",
1068
1300
  " relay repair-runtime [--no-restart] Internal: refresh existing Relay registrations and runtime services",
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+
3
+ // systemd's user manager is normally created before a shell session. Transient
4
+ // update/repair jobs therefore do not automatically inherit the paths and
5
+ // graphical-session values Relay used during setup. Keep this list narrow and
6
+ // deliberately free of credentials: these values are visible in unit metadata.
7
+ const LINUX_SYSTEMD_ENV_NAMES = Object.freeze([
8
+ "HOME",
9
+ "PATH",
10
+ "XDG_CONFIG_HOME",
11
+ "XDG_DATA_HOME",
12
+ "RELAY_CONFIG",
13
+ "RELAY_CONFIG_DIR",
14
+ "RELAY_HOME",
15
+ "RELAY_COMPANION_HOME",
16
+ "CLAUDE_HOME",
17
+ "CODEX_HOME",
18
+ "CLAUDE_CLI_PATH",
19
+ "CODEX_CLI_PATH",
20
+ "RELAY_TERMINAL",
21
+ "TERMINAL",
22
+ "RELAY_ALLOW_SANDBOX_AUTHORIZATION",
23
+ "CHROME_DEVEL_SANDBOX",
24
+ "LANG",
25
+ "LANGUAGE",
26
+ "LC_ALL",
27
+ "LC_CTYPE",
28
+ "SSH_AUTH_SOCK",
29
+ "SSL_CERT_FILE",
30
+ "SSL_CERT_DIR",
31
+ "NODE_EXTRA_CA_CERTS",
32
+ "DISPLAY",
33
+ "WAYLAND_DISPLAY",
34
+ "XAUTHORITY",
35
+ "DBUS_SESSION_BUS_ADDRESS",
36
+ "XDG_CURRENT_DESKTOP",
37
+ "XDG_SESSION_TYPE",
38
+ "XDG_RUNTIME_DIR",
39
+ ]);
40
+
41
+ const LINUX_GRAPHICAL_ENV_NAMES = Object.freeze([
42
+ "DISPLAY",
43
+ "WAYLAND_DISPLAY",
44
+ "XAUTHORITY",
45
+ "DBUS_SESSION_BUS_ADDRESS",
46
+ "XDG_CURRENT_DESKTOP",
47
+ "XDG_SESSION_TYPE",
48
+ "XDG_RUNTIME_DIR",
49
+ "SSH_AUTH_SOCK",
50
+ "SSL_CERT_FILE",
51
+ "SSL_CERT_DIR",
52
+ "NODE_EXTRA_CA_CERTS",
53
+ "LANG",
54
+ "LANGUAGE",
55
+ "LC_ALL",
56
+ "LC_CTYPE",
57
+ ]);
58
+
59
+ function cleanEnvironmentEntries(names, env = process.env) {
60
+ const entries = [];
61
+ for (const name of names) {
62
+ const value = env?.[name];
63
+ if (value == null || value === "") continue;
64
+ const text = String(value);
65
+ if (/\0|\r|\n/.test(text)) continue;
66
+ entries.push([name, text]);
67
+ }
68
+ return entries;
69
+ }
70
+
71
+ function systemdRunEnvironmentArgs(env = process.env) {
72
+ return cleanEnvironmentEntries(LINUX_SYSTEMD_ENV_NAMES, env)
73
+ .map(([name, value]) => `--setenv=${name}=${value}`);
74
+ }
75
+
76
+ function systemdImportEnvironmentArgs(env = process.env) {
77
+ const names = cleanEnvironmentEntries(LINUX_GRAPHICAL_ENV_NAMES, env).map(([name]) => name);
78
+ return names.length ? ["--user", "import-environment", ...names] : [];
79
+ }
80
+
81
+ module.exports = {
82
+ LINUX_GRAPHICAL_ENV_NAMES,
83
+ LINUX_SYSTEMD_ENV_NAMES,
84
+ cleanEnvironmentEntries,
85
+ systemdImportEnvironmentArgs,
86
+ systemdRunEnvironmentArgs,
87
+ };
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const fs = require("node:fs");
5
+ const os = require("node:os");
6
+ const path = require("node:path");
7
+ const { spawnSync } = require("node:child_process");
8
+
9
+ function nodeVersionSupported(version) {
10
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(String(version || "").trim());
11
+ if (!match) return false;
12
+ const major = Number(match[1]);
13
+ const minor = Number(match[2]);
14
+ return major > 22 || (major === 22 && minor >= 12);
15
+ }
16
+
17
+ function commandOutput(result) {
18
+ return String(result?.stdout ?? result?.out ?? "").trim();
19
+ }
20
+
21
+ function verifiedNodeVersion(executable, { runCommand = spawnSync } = {}) {
22
+ try {
23
+ const result = runCommand(executable, ["-p", "process.versions.node"], {
24
+ encoding: "utf8",
25
+ timeout: 5_000,
26
+ windowsHide: true,
27
+ });
28
+ const ok = result?.ok === true || (!result?.error && result?.status === 0);
29
+ const version = commandOutput(result);
30
+ return ok && nodeVersionSupported(version)
31
+ ? { ok: true, version }
32
+ : { ok: false, version, detail: result?.error?.message || result?.stderr || result?.out || "Node did not start" };
33
+ } catch (error) {
34
+ return { ok: false, version: "", detail: error?.message || String(error) };
35
+ }
36
+ }
37
+
38
+ function lexicalTemporaryNodePath(value, platform) {
39
+ if (platform === "win32" || !value) return false;
40
+ const normalized = path.posix.resolve(String(value).replaceAll("\\", "/"));
41
+ return normalized.startsWith("/tmp/") || normalized.startsWith("/private/tmp/");
42
+ }
43
+
44
+ function isTemporaryNodePath(executable, {
45
+ platform = process.platform,
46
+ realpathSync = fs.realpathSync,
47
+ } = {}) {
48
+ if (lexicalTemporaryNodePath(executable, platform)) return true;
49
+ try {
50
+ return lexicalTemporaryNodePath(realpathSync(executable), platform);
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ function fileDigest(file, fsImpl) {
57
+ return crypto.createHash("sha256").update(fsImpl.readFileSync(file)).digest("hex");
58
+ }
59
+
60
+ /**
61
+ * Preserve a temporary Node executable inside Relay's canonical runtime.
62
+ * Content addressing makes reuse safe, while an execution check after copying
63
+ * catches incomplete binaries and runtimes that depend on files beside them.
64
+ */
65
+ function relayOwnedNodePath(executable, {
66
+ platform = process.platform,
67
+ runtimeRoot = path.join(os.homedir(), ".relay", "runtime"),
68
+ fsImpl = fs,
69
+ runCommand = spawnSync,
70
+ realpathSync = fs.realpathSync,
71
+ isTemporary = isTemporaryNodePath,
72
+ randomBytes = crypto.randomBytes,
73
+ } = {}) {
74
+ if (!executable || !isTemporary(executable, { platform, realpathSync })) return executable;
75
+
76
+ let source;
77
+ try {
78
+ source = realpathSync(executable);
79
+ } catch (error) {
80
+ throw new Error(`Relay could not resolve its temporary Node runtime (${error?.message || error}).`);
81
+ }
82
+ const sourceRuntime = verifiedNodeVersion(source, { runCommand });
83
+ if (!sourceRuntime.ok) {
84
+ throw new Error(`Relay refused an invalid temporary Node runtime (${sourceRuntime.detail || sourceRuntime.version || source}).`);
85
+ }
86
+
87
+ let digest;
88
+ try {
89
+ digest = fileDigest(source, fsImpl);
90
+ } catch (error) {
91
+ throw new Error(`Relay could not read its temporary Node runtime (${error?.message || error}).`);
92
+ }
93
+ const api = platform === "win32" ? path.win32 : path.posix;
94
+ const directory = api.join(runtimeRoot, "node", digest);
95
+ const destination = api.join(directory, platform === "win32" ? "node.exe" : "node");
96
+
97
+ try {
98
+ if (fileDigest(destination, fsImpl) === digest) {
99
+ const existingRuntime = verifiedNodeVersion(destination, { runCommand });
100
+ if (existingRuntime.ok && existingRuntime.version === sourceRuntime.version) {
101
+ fsImpl.chmodSync(directory, 0o700);
102
+ fsImpl.chmodSync(destination, 0o700);
103
+ return destination;
104
+ }
105
+ }
106
+ } catch {}
107
+
108
+ try {
109
+ fsImpl.mkdirSync(directory, { recursive: true, mode: 0o700 });
110
+ fsImpl.chmodSync(directory, 0o700);
111
+ const temporary = api.join(directory, `.node-${process.pid}-${randomBytes(6).toString("hex")}`);
112
+ try {
113
+ fsImpl.writeFileSync(temporary, fsImpl.readFileSync(source), { mode: 0o700, flag: "wx" });
114
+ fsImpl.chmodSync(temporary, 0o700);
115
+ if (fileDigest(temporary, fsImpl) !== digest) throw new Error("the copied executable failed its integrity check");
116
+ fsImpl.rmSync(destination, { force: true });
117
+ fsImpl.renameSync(temporary, destination);
118
+ } finally {
119
+ try { fsImpl.rmSync(temporary, { force: true }); } catch {}
120
+ }
121
+ fsImpl.chmodSync(destination, 0o700);
122
+ } catch (error) {
123
+ throw new Error(`Relay could not install its owned Node runtime (${error?.message || error}).`);
124
+ }
125
+
126
+ const installedRuntime = verifiedNodeVersion(destination, { runCommand });
127
+ if (!installedRuntime.ok || installedRuntime.version !== sourceRuntime.version) {
128
+ try { fsImpl.rmSync(destination, { force: true }); } catch {}
129
+ throw new Error(`Relay's owned Node runtime failed verification (${installedRuntime.detail || installedRuntime.version || destination}).`);
130
+ }
131
+ return destination;
132
+ }
133
+
134
+ module.exports = {
135
+ isTemporaryNodePath,
136
+ nodeVersionSupported,
137
+ relayOwnedNodePath,
138
+ verifiedNodeVersion,
139
+ };