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 +2 -2
- package/bin/relay.js +241 -9
- package/bootstrap/linux-systemd.cjs +87 -0
- package/bootstrap/owned-node-runtime.cjs +139 -0
- package/bootstrap/relay-setup.cjs +262 -7
- package/bootstrap/runtime-executables.cjs +48 -0
- package/bootstrap/runtime-health.cjs +70 -13
- package/npm-shrinkwrap.json +2 -2
- package/overlay/deep-link.cjs +2 -13
- package/overlay/inbox.html +46 -3
- package/overlay/linux-runtime-probe.cjs +44 -0
- package/overlay/linux-terminal.cjs +106 -20
- package/overlay/main.cjs +40 -24
- package/package.json +1 -1
- package/src/auto-update.js +14 -1
- package/src/canonical-runtime.js +44 -0
- package/src/canonical-updater.js +31 -6
- package/src/cli-trampoline.js +6 -3
- package/src/install.js +770 -51
- package/src/mcp-launcher.js +6 -1
- package/src/mcp.js +3 -0
- package/src/provider-auth.js +43 -17
- package/src/session-controller.js +12 -65
- package/src/update-agent-cleanup.js +120 -0
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
+
};
|