relay-companion 0.1.552 → 0.1.554

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.
@@ -24,6 +24,7 @@ function read(file) {
24
24
  catch (error) { if (error.code === "ENOENT") return null; throw error; }
25
25
  }
26
26
  function updateConsent({ homeDir, env = process.env }) {
27
+ if (require("./recovery-intent.cjs").stopped(homeDir)) return { ok: false, reason: "intentionally-stopped" };
27
28
  if (/^(0|false|off|no)$/i.test(String(env.RELAY_AUTO_UPDATE || ""))
28
29
  || read(path.join(homeDir, ".relay", "recovery", "policy.json"))?.autoUpdate === false) return { ok: false, reason: "updates-disabled" };
29
30
  const root = path.join(homeDir, ".relay");
@@ -136,6 +136,7 @@ async function installFromApplication({ resourcesDir, applicationRoot, executabl
136
136
  throw new Error("Legacy installation requires the separately planned repair route");
137
137
  }
138
138
  if (current && versionCompare(bundle.receipt.version, current.version) < 0) throw new Error("An older installer cannot downgrade Relay");
139
+ require("./recovery-intent.cjs").setStopped(false, homeDir);
139
140
  // Live services, not files, decide whether the existing Relay is healthy.
140
141
  // A runtime whose daemon and pill are not running gets one repair through
141
142
  // its own CLI before it is judged; only a runtime that stays down blocks.
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ // All application-facing repairs use the same ownership record as activation.
3
+ // A child of an existing installer joins that exact generation; it never takes
4
+ // an unrelated lock or trusts a caller-supplied boolean saying "updating".
5
+ const fs = require("node:fs"), os = require("node:os"), path = require("node:path");
6
+ const locks = require("./recovery-launcher.cjs");
7
+ const crypto = require("node:crypto");
8
+ const heldLeases = new Map();
9
+ const read = file => { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } };
10
+
11
+ function lifecycleOwnership({ homeDir = os.homedir(), env = process.env,
12
+ acquire = locks.acquireCanonicalLock, pid = process.pid, parentPid = process.ppid,
13
+ alive = locks.processAlive, identity = locks.nativeProcessIdentity } = {}) {
14
+ const file = path.join(homeDir, ".relay", "runtime", "transaction.lock", "owner.json");
15
+ const observed = read(file);
16
+ let lock;
17
+ const delegated = observed && (observed.pid === parentPid
18
+ || (env.RELAY_LIFECYCLE_OWNER === `${observed.pid}:${observed.nonce}`));
19
+ const held = heldLeases.get(file);
20
+ if (delegated && held?.owner.nonce === observed.nonce) {
21
+ held.assert();
22
+ held.references++;
23
+ let released = false;
24
+ return { ...held, release: () => { if (released) return; released = true; if (--held.references === 0) { heldLeases.delete(file); held.dispose(); } } };
25
+ }
26
+ if (!delegated) lock = acquire(path.dirname(file));
27
+ const owner = read(file);
28
+ const lockDir = path.dirname(file);
29
+ let participant;
30
+ // Only join while the granting process still exists. A stale environment
31
+ // capability cannot resurrect a dead transaction.
32
+ if (delegated && owner?.pid !== pid) {
33
+ if (!alive(owner?.pid) || (owner.processIdentity && identity(owner.pid) !== owner.processIdentity)) throw Error("lifecycle-owner-ended");
34
+ participant = path.join(lockDir, `participant-${pid}-${crypto.randomBytes(16).toString("hex")}.json`);
35
+ fs.writeFileSync(participant, JSON.stringify({ nonce: owner.nonce, pid, createdAt: Date.now(), processIdentity: identity(pid) }), { flag: "wx", mode: 0o600 });
36
+ }
37
+ const assert = () => {
38
+ const current = read(file);
39
+ if (!owner?.nonce || current?.nonce !== owner.nonce || current.pid !== owner.pid
40
+ || fs.existsSync(path.join(lockDir, "reclaim.json"))
41
+ || (!participant && (!alive(owner.pid) || (owner.processIdentity && identity(owner.pid) !== owner.processIdentity)))) {
42
+ throw Error("lifecycle-owner-changed");
43
+ }
44
+ };
45
+ const dispose = () => {
46
+ if (participant && read(file)?.nonce === owner.nonce) fs.rmSync(participant, { force: true });
47
+ lock?.release();
48
+ };
49
+ try { assert(); } catch (error) { dispose(); throw error; }
50
+ const lease = {
51
+ owner, assert, delegated: !lock,
52
+ env: { ...env, RELAY_LIFECYCLE_OWNER: `${owner.pid}:${owner.nonce}` },
53
+ references: 1, dispose,
54
+ };
55
+ heldLeases.set(file, lease);
56
+ let released = false;
57
+ return { ...lease, release: () => {
58
+ if (released) return;
59
+ released = true;
60
+ if (--lease.references === 0) { heldLeases.delete(file); dispose(); }
61
+ } };
62
+ }
63
+ function ownerEnvironment({ homeDir = os.homedir(), env = process.env } = {}) {
64
+ const owner = read(path.join(homeDir, ".relay", "runtime", "transaction.lock", "owner.json"));
65
+ // Only the actual owner may hand its generation to a worker.
66
+ return owner?.pid === process.pid && owner.nonce
67
+ ? { ...env, RELAY_LIFECYCLE_OWNER: `${owner.pid}:${owner.nonce}` } : { ...env };
68
+ }
69
+ module.exports = { lifecycleOwnership, ownerEnvironment };
@@ -26,6 +26,7 @@ function readRegistration(label, { homeDir = os.homedir(), run = defaultRun, fsI
26
26
  const plist = JSON.parse(decoded.stdout);
27
27
  const args = plist.ProgramArguments;
28
28
  if (plist.Label !== label || !Array.isArray(args) || !args.every(x => typeof x === "string") || plist.Program) throw Error(`invalid-service-plist: ${label}`);
29
+ if (label === LABELS[0] && require("./node-contract.cjs").isElectronExecutable(args[0])) throw Error(`invalid-service-interpreter: ${label}`);
29
30
  const suffix = label === LABELS[0] ? /[\\/]bin[\\/]relay\.js$/ : /[\\/]overlay[\\/]main\.cjs$/;
30
31
  const script = args.find(x => /[\\/]node_modules[\\/]relay-companion[\\/]/.test(x) && suffix.test(x));
31
32
  if (!script || !path.isAbsolute(args[0]) || !path.isAbsolute(script) || (label === LABELS[0] && !args.includes("daemon"))) throw Error(`invalid-service-target: ${label}`);
@@ -84,7 +85,7 @@ async function restartMacRegisteredServices(target, {
84
85
  healthCheck = require("./runtime-health.cjs").exactRuntimeHealth,
85
86
  sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
86
87
  } = {}) {
87
- let lock;
88
+ let lock, releaseDrain;
88
89
  try { lock = acquireLock(path.join(homeDir, ".relay", "runtime", "transaction.lock")); }
89
90
  catch { return { ok: false, reason: "deferred-update-owner" }; }
90
91
  try {
@@ -92,6 +93,7 @@ async function restartMacRegisteredServices(target, {
92
93
  if (!current?.active || current.packageRoot !== target.packageRoot) return { ok: false, reason: "runtime-changed" };
93
94
  const records = LABELS.map(label => readRegistration(label, { homeDir, run }));
94
95
  if (records.some(r => r.packageRoot !== target.packageRoot)) return { ok: false, reason: "runtime-changed" };
96
+ releaseDrain = await require("./update-activity.cjs").drainCalls({ homeDir, sleep });
95
97
  for (const record of records) {
96
98
  const observed = registration(record.label, { run, userId });
97
99
  if (!observed.known) return { ok: false, reason: "service-registration-query-failed" };
@@ -105,7 +107,7 @@ async function restartMacRegisteredServices(target, {
105
107
  }
106
108
  return { ok: false, reason: "restarted-runtime-not-healthy" };
107
109
  } catch (error) { return { ok: false, reason: error.message }; }
108
- finally { lock.release(); }
110
+ finally { releaseDrain?.(); lock.release(); }
109
111
  }
110
112
 
111
113
  module.exports = { LABELS, registration, readRegistration, repairMacServiceRegistrations, restartMacRegisteredServices };
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ // Shared by setup, application workers and the independent recovery bundle.
3
+ // An interpreter is an executable contract, never the identity of its caller.
4
+ const fs = require("node:fs"), os = require("node:os"), path = require("node:path");
5
+ const { spawnSync } = require("node:child_process");
6
+
7
+ function isElectronExecutable(executable, { realpath = fs.realpathSync } = {}) {
8
+ const electron = value => /(?:^|[\\/])(?:electron|relay)(?:\.exe|\.app(?:[\\/]|$)|$)|\.app[\\/]Contents[\\/]MacOS[\\/]/i.test(String(value || ""));
9
+ if (electron(executable) || (executable === process.execPath && process.versions.electron)) return true;
10
+ try { return electron(realpath(executable)); } catch { return false; }
11
+ }
12
+ function nodeEnvironment(env = process.env) {
13
+ const clean = { ...env };
14
+ for (const key of Object.keys(clean)) {
15
+ if (/^(NODE_OPTIONS|NODE_PATH|ELECTRON_RUN_AS_NODE)$/i.test(key)) delete clean[key];
16
+ }
17
+ return clean;
18
+ }
19
+ function verifyNode(executable, { run = spawnSync, env = process.env, realpath = fs.realpathSync } = {}) {
20
+ if (!executable || isElectronExecutable(executable, { realpath })) return { ok: false, reason: "node-is-electron" };
21
+ try {
22
+ const result = run(executable, ["-p", "process.versions.electron ? '' : process.versions.node"],
23
+ { encoding: "utf8", windowsHide: true, timeout: 5000, env: nodeEnvironment(env) });
24
+ const version = String(result?.stdout ?? result?.out ?? "").trim();
25
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
26
+ const ok = !result?.error && (result?.status === 0 || result?.ok === true)
27
+ && match && (Number(match[1]) > 22 || (Number(match[1]) === 22 && Number(match[2]) >= 12));
28
+ return { ok: Boolean(ok), version, reason: ok ? null : "node-contract-failed" };
29
+ } catch (error) { return { ok: false, reason: "node-contract-failed", detail: error.message }; }
30
+ }
31
+ function resolveManagedNode({ homeDir = os.homedir(), node = process.execPath, run = spawnSync,
32
+ env = process.env, read = file => JSON.parse(fs.readFileSync(file, "utf8")) } = {}) {
33
+ const candidates = [];
34
+ for (const file of ["recovery/current.json", "recovery/known-good.json", "recovery/launcher-node.json", "runtime/current.json"]) {
35
+ try { const value = read(path.join(homeDir, ".relay", file)); if (value?.node) candidates.push(value.node); } catch {}
36
+ }
37
+ candidates.push(node);
38
+ for (const candidate of new Set(candidates)) if (verifyNode(candidate, { run, env }).ok) return candidate;
39
+ throw Error("No verified Relay Node runtime is available; open the Relay installer to restore it.");
40
+ }
41
+ module.exports = { isElectronExecutable, nodeEnvironment, verifyNode, resolveManagedNode };
@@ -5,6 +5,7 @@ const fs = require("node:fs");
5
5
  const os = require("node:os");
6
6
  const path = require("node:path");
7
7
  const { spawnSync } = require("node:child_process");
8
+ const { isElectronExecutable, nodeEnvironment } = require("./node-contract.cjs");
8
9
 
9
10
  function nodeVersionSupported(version) {
10
11
  const match = /^(\d+)\.(\d+)\.(\d+)/.exec(String(version || "").trim());
@@ -19,11 +20,13 @@ function commandOutput(result) {
19
20
  }
20
21
 
21
22
  function verifiedNodeVersion(executable, { runCommand = spawnSync, timeout = 5_000 } = {}) {
23
+ if (isElectronExecutable(executable)) return { ok: false, version: "", detail: "Electron is not a service Node runtime" };
22
24
  try {
23
- const result = runCommand(executable, ["-p", "process.versions.node"], {
25
+ const result = runCommand(executable, ["-p", "process.versions.electron ? '' : process.versions.node"], {
24
26
  encoding: "utf8",
25
27
  timeout,
26
28
  windowsHide: true,
29
+ env: nodeEnvironment(),
27
30
  });
28
31
  const ok = result?.ok === true || (!result?.error && result?.status === 0);
29
32
  const version = commandOutput(result);
@@ -71,6 +74,7 @@ function relayOwnedNodePath(executable, {
71
74
  isTemporary = isTemporaryNodePath,
72
75
  randomBytes = crypto.randomBytes,
73
76
  } = {}) {
77
+ if (isElectronExecutable(executable, { realpath: realpathSync })) throw new Error("Electron is not a service Node runtime");
74
78
  if (!executable || !isTemporary(executable, { platform, realpathSync })) return executable;
75
79
 
76
80
  let source;
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ // Observation is not permission to edit service registrations. Wake the
3
+ // independently installed controller and let it judge the current generation.
4
+ const fs = require("node:fs"), os = require("node:os"), path = require("node:path");
5
+ const { spawnSync } = require("node:child_process");
6
+ const { atomicFile } = require("./mac-registration-transaction.cjs");
7
+ const { resolveManagedNode, nodeEnvironment } = require("./node-contract.cjs");
8
+ const { lifecycleOwnership } = require("./lifecycle-ownership.cjs");
9
+ const { Worker, isMainThread, workerData, parentPort } = require("node:worker_threads");
10
+ const ok = r => !r?.error && (r?.status === 0 || r?.ok === true);
11
+
12
+ function requestRecovery({ homeDir = os.homedir(), platform = process.platform, reason = "health-check",
13
+ packageRoot, node, env = process.env, now = Date.now,
14
+ run = (file, args, options) => spawnSync(file, args, { encoding: "utf8", windowsHide: true, timeout: 15_000, ...options }),
15
+ install = options => require("./recovery-install.cjs").installRecovery(options),
16
+ own = lifecycleOwnership, userId = process.getuid?.() ?? 0 } = {}) {
17
+ const root = path.join(homeDir, ".relay", "recovery");
18
+ if (require("./recovery-intent.cjs").stopped(homeDir)) return { ok: false, reason: "intentionally-stopped" };
19
+ const label = "work.relay.companion.recovery", task = "Relay Companion Recovery";
20
+ const query = platform === "win32" ? ["schtasks.exe", ["/Query", "/TN", task]]
21
+ : platform === "darwin" ? ["/bin/launchctl", ["print", `gui/${userId}/${label}`]]
22
+ : ["systemctl", ["--user", "show", "--property=LoadState", "--value", `${label}.service`]];
23
+ const observed = run(...query);
24
+ const text = String(observed?.stdout ?? observed?.out ?? "");
25
+ const present = ok(observed) && (platform !== "linux" || text.trim() === "loaded");
26
+ if (!present) {
27
+ // A failed query is not proof of absence. In particular, do not rewrite a
28
+ // launch agent because its GUI domain is temporarily unavailable at login.
29
+ const detail = String(observed?.stderr ?? observed?.out ?? "");
30
+ const missing = !observed?.error && (platform === "darwin"
31
+ ? observed.status === 113 || /Could not find service/i.test(detail)
32
+ : platform === "linux" ? text.trim() === "not-found"
33
+ : /cannot find|does not exist|not found/i.test(detail));
34
+ if (!missing || !packageRoot) return { ok: false, reason: "recovery-registration-unavailable" };
35
+ let lease;
36
+ try {
37
+ lease = own({ homeDir, env });
38
+ const interpreter = resolveManagedNode({ homeDir, node, run, env });
39
+ lease.assert();
40
+ const result = install({ packageRoot, node: interpreter, homeDir, platform, reload: true, runCommand: run });
41
+ if (!result.ok) return { ok: false, reason: result.reason, detail: result.detail };
42
+ } catch (error) { return { ok: false, reason: "recovery-bootstrap-deferred", detail: error.message }; }
43
+ finally { lease?.release(); }
44
+ }
45
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
46
+ atomicFile(path.join(root, "request.json"), JSON.stringify({ schema: 1, reason, at: now() }));
47
+ const command = platform === "win32" ? ["schtasks.exe", ["/Run", "/TN", task]]
48
+ : platform === "darwin" ? ["/bin/launchctl", ["kickstart", `gui/${userId}/${label}`]]
49
+ : ["systemctl", ["--user", "start", "--no-block", `${label}.service`]];
50
+ const submitted = run(...command, { env: nodeEnvironment(env) });
51
+ return { ok: ok(submitted), reason: ok(submitted) ? "recovery-requested" : "recovery-start-failed",
52
+ detail: ok(submitted) ? "" : String(submitted?.error?.message || submitted?.stderr || submitted?.out || "") };
53
+ }
54
+ // launchctl, Task Scheduler and interpreter verification must never block the
55
+ // Electron event loop whose responsiveness we are trying to preserve.
56
+ function requestRecoveryAsync(options = {}) {
57
+ return new Promise((resolve) => {
58
+ const worker = new Worker(__filename, { workerData: { relayRecoveryRequest: options } });
59
+ let settled = false;
60
+ const finish = result => { if (!settled) { settled = true; clearTimeout(timer); resolve(result); } };
61
+ const timer = setTimeout(() => {
62
+ void worker.terminate();
63
+ finish({ ok: false, reason: "recovery-request-deadline" });
64
+ }, 120_000);
65
+ worker.once("message", finish);
66
+ worker.once("error", error => finish({ ok: false, reason: "recovery-request-failed", detail: error.message }));
67
+ worker.once("exit", () => finish({ ok: false, reason: "recovery-request-exited" }));
68
+ });
69
+ }
70
+ if (!isMainThread && workerData?.relayRecoveryRequest) {
71
+ try { parentPort.postMessage(requestRecovery(workerData.relayRecoveryRequest)); }
72
+ catch (error) { parentPort.postMessage({ ok: false, reason: "recovery-request-failed", detail: error.message }); }
73
+ }
74
+ module.exports = { requestRecovery, requestRecoveryAsync };
@@ -52,7 +52,7 @@ function windowsRecoveryTaskXml(script, startAt = new Date()) {
52
52
  <Triggers>
53
53
  <TimeTrigger>
54
54
  <StartBoundary>${boundary}</StartBoundary>
55
- <Repetition><Interval>PT5M</Interval><StopAtDurationEnd>false</StopAtDurationEnd></Repetition>
55
+ <Repetition><Interval>PT1M</Interval><StopAtDurationEnd>false</StopAtDurationEnd></Repetition>
56
56
  <Enabled>true</Enabled>
57
57
  </TimeTrigger>
58
58
  </Triggers>
@@ -183,7 +183,8 @@ function installRecovery({ packageRoot, node = process.execPath, homeDir = os.ho
183
183
  } else if (platform === "darwin") {
184
184
  const plist = path.join(homeDir, "Library", "LaunchAgents", `${LABEL}.plist`);
185
185
  fs.mkdirSync(path.dirname(plist), { recursive: true });
186
- atomicFile(plist, `<?xml version="1.0"?><plist version="1.0"><dict><key>Label</key><string>${LABEL}</string><key>ProgramArguments</key><array><string>${xml(launcherNode)}</string><string>${xml(launcher)}</string></array><key>EnvironmentVariables</key><dict><key>HOME</key><string>${xml(homeDir)}</string></dict><key>StartInterval</key><integer>300</integer><key>RunAtLoad</key><true/><key>ProcessType</key><string>Background</string><key>StandardOutPath</key><string>${xml(log)}</string><key>StandardErrorPath</key><string>${xml(log)}</string></dict></plist>`);
186
+ const priorSchedule = fs.existsSync(plist) ? fs.readFileSync(plist) : null;
187
+ atomicFile(plist, `<?xml version="1.0"?><plist version="1.0"><dict><key>Label</key><string>${LABEL}</string><key>ProgramArguments</key><array><string>${xml(launcherNode)}</string><string>${xml(launcher)}</string></array><key>EnvironmentVariables</key><dict><key>HOME</key><string>${xml(homeDir)}</string></dict><key>StartInterval</key><integer>60</integer><key>RunAtLoad</key><true/><key>ProcessType</key><string>Background</string><key>StandardOutPath</key><string>${xml(log)}</string><key>StandardErrorPath</key><string>${xml(log)}</string></dict></plist>`);
187
188
  // The job dispatches through the stable host. Do not unload it on update.
188
189
  const observed = runCommand("launchctl", ["print", `gui/${userId}/${LABEL}`]);
189
190
  if (!ok(observed)) {
@@ -191,12 +192,23 @@ function installRecovery({ packageRoot, node = process.execPath, homeDir = os.ho
191
192
  if (!missing) throw Error("recovery-registration-query-failed");
192
193
  results.push(runCommand("launchctl", ["bootstrap", `gui/${userId}`, plist]));
193
194
  results.push(runCommand("launchctl", ["print", `gui/${userId}/${LABEL}`]));
195
+ write(path.join(root, "scheduler.json"), { schema: 1, intervalSeconds: 60, at: Date.now() });
196
+ }
197
+ if (ok(observed) && read(path.join(root, "scheduler.json"))?.intervalSeconds !== 60) {
198
+ const job = "work.relay.recovery.schedule-handover";
199
+ const pending = runCommand("launchctl", ["list", job]);
200
+ if (!(ok(pending) && /"PID"\s*=\s*[1-9]\d*/.test(String(pending.stdout || pending.out || "")))) {
201
+ if (ok(pending)) runCommand("launchctl", ["remove", job]);
202
+ if (priorSchedule) atomicFile(path.join(root, "scheduler-previous.plist"), priorSchedule);
203
+ results.push(runCommand("launchctl", ["submit", "-l", job, "-o", log, "-e", log, "--", runtimeNode,
204
+ path.join(bundle, "bootstrap", "recovery-schedule-handover.cjs"), homeDir, String(userId)]));
205
+ }
194
206
  }
195
207
  } else {
196
208
  const dir = path.join(homeDir, ".config", "systemd", "user");
197
209
  fs.mkdirSync(dir, { recursive: true });
198
210
  atomicFile(path.join(dir, `${LABEL}.service`), `[Unit]\nDescription=Relay update recovery\n[Service]\nType=oneshot\nExecStart=${unit(launcherNode)} ${unit(launcher)}\nTimeoutStartSec=1800\nKillMode=control-group\n`);
199
- atomicFile(path.join(dir, `${LABEL}.timer`), `[Unit]\nDescription=Check Relay update health\n[Timer]\nOnBootSec=2min\nOnUnitInactiveSec=5min\nPersistent=true\n[Install]\nWantedBy=timers.target\n`);
211
+ atomicFile(path.join(dir, `${LABEL}.timer`), `[Unit]\nDescription=Check Relay update health\n[Timer]\nOnBootSec=15s\nOnUnitInactiveSec=30s\nPersistent=true\n[Install]\nWantedBy=timers.target\n`);
200
212
  // The user manager can retain a different HOME from this installer.
201
213
  // Registration is required even when thin setup defers starting services.
202
214
  for (const ext of ["service", "timer"]) {
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ const fs = require("node:fs"), os = require("node:os"), path = require("node:path");
3
+ const file = homeDir => path.join(homeDir, ".relay", "recovery", "intent.json");
4
+ function stopped(homeDir = os.homedir()) {
5
+ try { return JSON.parse(fs.readFileSync(file(homeDir), "utf8")).stopped === true; }
6
+ catch (error) { return error.code !== "ENOENT"; }
7
+ }
8
+ function setStopped(value, homeDir = os.homedir()) {
9
+ require("./recovery-launcher.cjs").write(file(homeDir), { schema: 1, stopped: value, at: Date.now() });
10
+ }
11
+ module.exports = { stopped, setStopped };
@@ -6,8 +6,26 @@ function read(file) { try { return JSON.parse(fs.readFileSync(file, "utf8")); }
6
6
  function write(file, data) {
7
7
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
8
8
  const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
9
- fs.writeFileSync(temp, JSON.stringify(data) + "\n", { mode: 0o600 });
10
- fs.renameSync(temp, file);
9
+ let fd;
10
+ try {
11
+ fd = fs.openSync(temp, "wx", 0o600);
12
+ fs.writeFileSync(fd, JSON.stringify(data) + "\n");
13
+ fs.fsyncSync(fd); fs.closeSync(fd); fd = undefined;
14
+ const start = Date.now();
15
+ for (;;) {
16
+ try { fs.renameSync(temp, file); break; }
17
+ catch (error) {
18
+ if (!["EPERM", "EBUSY", "EACCES"].includes(error.code) || Date.now() - start > 3000) throw error;
19
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
20
+ }
21
+ }
22
+ if (process.platform !== "win32") {
23
+ fd = fs.openSync(path.dirname(file), "r"); fs.fsyncSync(fd); fs.closeSync(fd); fd = undefined;
24
+ }
25
+ } finally {
26
+ if (fd !== undefined) fs.closeSync(fd);
27
+ try { fs.unlinkSync(temp); } catch (error) { if (error.code !== "ENOENT") throw error; }
28
+ }
11
29
  }
12
30
  function validPointer(p, root) {
13
31
  return p?.schema === 1 && /^\d+\.\d+\.\d+$/.test(p.version || "")
@@ -198,6 +216,27 @@ function acquireCanonicalReclaimClaim(reclaimPath, {
198
216
  return false;
199
217
  }
200
218
 
219
+ // Delegated mutation workers retain the generation after their parent dies.
220
+ // Unknown records fail closed. Reclaimers check again after winning the claim;
221
+ // workers publish before checking that no reclaim is underway.
222
+ function liveLockParticipants(lockPath, nonce, {
223
+ readdirSync = fs.readdirSync, readFileSync = fs.readFileSync,
224
+ isProcessAlive = processAlive, processIdentity = nativeProcessIdentity,
225
+ } = {}) {
226
+ let names;
227
+ try { names = readdirSync(lockPath); } catch (error) { return error.code !== "ENOENT"; }
228
+ for (const name of names) {
229
+ const match = /^participant-([1-9][0-9]*)-[0-9a-f]+\.json$/.exec(name);
230
+ if (!match) continue;
231
+ let member;
232
+ try { member = JSON.parse(readFileSync(path.join(lockPath, name), "utf8")); }
233
+ catch (error) { if (error.code === "ENOENT" || !isProcessAlive(Number(match[1]))) continue; return true; }
234
+ if (member.nonce !== nonce) continue;
235
+ if (canonicalLockOwnerState(member, { isProcessAlive, processIdentity }) !== "dead") return true;
236
+ }
237
+ return false;
238
+ }
239
+
201
240
  function acquireCanonicalLock(lockPath, {
202
241
  now = Date.now,
203
242
  // Preserve the shipped bootstrap's two-hour grace for incomplete records.
@@ -285,7 +324,7 @@ function acquireCanonicalLock(lockPath, {
285
324
  release() {
286
325
  let owner = null;
287
326
  try { owner = JSON.parse(readFileSync(ownerPath, "utf8")); } catch {}
288
- if (owner?.nonce === nonce) rmSync(lockPath, { recursive: true, force: true });
327
+ if (owner?.nonce === nonce && !liveLockParticipants(lockPath, nonce)) rmSync(lockPath, { recursive: true, force: true });
289
328
  },
290
329
  };
291
330
  } catch (error) {
@@ -314,7 +353,7 @@ function acquireCanonicalLock(lockPath, {
314
353
  // have observed the directory between mkdir and owner.json being written.
315
354
  const stale = ownerState === "dead"
316
355
  || (ownerState === "unknown" && observedAt > 0 && now() - observedAt > staleAfterMs);
317
- if (!stale || attempt > 0) lockFail("Another verified Relay install or update is already in progress.");
356
+ if (!stale || attempt > 0 || liveLockParticipants(lockPath, owner?.nonce, { readdirSync, readFileSync, isProcessAlive, processIdentity })) lockFail("Another verified Relay install or update is already in progress.");
318
357
 
319
358
  // Serialize reclaimers inside this exact lock generation, then re-read the
320
359
  // owner after winning. This closes the race where one retry replaced the
@@ -356,7 +395,7 @@ function acquireCanonicalLock(lockPath, {
356
395
  const confirmedState = canonicalLockOwnerState(confirmedOwner, { isProcessAlive, processIdentity });
357
396
  const confirmedStale = confirmedState === "dead"
358
397
  || (confirmedState === "unknown" && confirmedAt > 0 && now() - confirmedAt > staleAfterMs);
359
- if (!confirmedStale) lockFail("Another verified Relay install or update is already in progress.");
398
+ if (!confirmedStale || liveLockParticipants(lockPath, confirmedOwner?.nonce, { readdirSync, readFileSync, isProcessAlive, processIdentity })) lockFail("Another verified Relay install or update is already in progress.");
360
399
  let confirmedClaim = null;
361
400
  try { confirmedClaim = JSON.parse(readFileSync(reclaimPath, "utf8")); } catch {}
362
401
  if (confirmedClaim?.nonce !== nonce) lockFail("Another verified Relay install or update is already in progress.");
@@ -525,5 +564,5 @@ async function launch({ root = __dirname, run = runChild, now = Date.now, env =
525
564
  return { ok: false, status: "failed" };
526
565
  } finally { release(); }
527
566
  }
528
- module.exports = { launch, validPointer, read, write, acquireLauncherLock, appendRecoveryLog, acquireCanonicalLock, processAlive, nativeProcessIdentity, nativeIdentityBirth };
567
+ module.exports = { launch, validPointer, read, write, acquireLauncherLock, appendRecoveryLog, acquireCanonicalLock, processAlive, nativeProcessIdentity, nativeIdentityBirth, liveLockParticipants };
529
568
  if (require.main === module) launch().then(result => { process.exitCode = result.ok ? 0 : 1; }).catch(e => { console.error(e.message); process.exitCode = 1; });
@@ -11,7 +11,7 @@ const { stageVerifiedRuntime, releasePlatform } = require("./relay-setup.cjs");
11
11
  const { verifyReleaseEnvelope } = require("./release-signature.cjs");
12
12
  const { inFlightTransaction, workerLostLock } = require("./recovery-transaction.cjs");
13
13
  const trust = require("./trust.json");
14
- const CHECK_MS = 5 * 60_000;
14
+ const CHECK_MS = 60_000;
15
15
  const DEADLINE_MS = 25 * 60_000;
16
16
  const HEARTBEAT_MS = 60_000;
17
17
  const BUSY_GRACE_MS = 15 * 60_000;
@@ -22,10 +22,7 @@ const STARTUP_GRACE_MS = 3 * 60_000;
22
22
 
23
23
  function read(file) { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } }
24
24
  function write(file, value) {
25
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
26
- const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
27
- fs.writeFileSync(tmp, JSON.stringify(value) + "\n", { mode: 0o600 });
28
- fs.renameSync(tmp, file);
25
+ require("./recovery-launcher.cjs").write(file, value);
29
26
  }
30
27
  function compare(a, b) {
31
28
  if (!/^\d+\.\d+\.\d+$/.test(a || "") || !/^\d+\.\d+\.\d+$/.test(b || "")) return null;
@@ -118,10 +115,9 @@ function execute(node, entry, args, { timeoutMs = DEADLINE_MS, spawnImpl = spawn
118
115
  // daemon, or a machine so starved that a healthy daemon missed a few ticks.
119
116
  // Only a heartbeat that stays stale across two scheduled checks proves the
120
117
  // former while the process is still alive. A missing process needs no second look.
121
- // Ten minutes, two scheduled checks apart: a daemon whose process is present
122
- // and whose heartbeat was recent is far more often slow than dead, and every
123
- // false restart takes the person's Companion away with it.
124
- const STALE_CONFIRM_MS = 10 * 60_000;
118
+ // Confirm across consecutive awake observations. A gap caused by sleep or a
119
+ // stalled scheduler resets this window rather than counting as a live hang.
120
+ const STALE_CONFIRM_MS = 60_000;
125
121
  const MAX_IN_PLACE_RESTARTS = 2;
126
122
 
127
123
  // A runner killed mid-extraction by the launcher's deadline leaves its staged
@@ -163,8 +159,13 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
163
159
  policyFactory = require("./recovery-policy.cjs").recoveryPolicy,
164
160
  validateLocal = require("./recovery-local.cjs").validateLocalRuntime,
165
161
  transactions = inFlightTransaction,
166
- repairServices = require("./mac-service-recovery.cjs").repairMacServiceRegistrations } = {}) {
162
+ repairServices = require("./service-recovery.cjs").repairServiceRegistrations } = {}) {
167
163
  const root = path.join(homeDir, ".relay");
164
+ if (require("./recovery-intent.cjs").stopped(homeDir)) {
165
+ const result = { ok: true, status: "intentionally-stopped", runtimeHealthy: false };
166
+ write(path.join(root, "recovery", "status.json"), { schema: 1, checkedAt: now(), runId: env.RELAY_RECOVERY_RUN_ID || null, launcherVersion: require("../package.json").version, ...result });
167
+ return result;
168
+ }
168
169
  // Local availability is independent of update eligibility, network, memory,
169
170
  // and the journal's active flag. This helper shares the activation lock.
170
171
  let progress, reservedService;
@@ -177,9 +178,10 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
177
178
  return { ...repaired, ok: false, status: "repair-progress-unavailable", runtimeHealthy: false, lastError: error.message };
178
179
  }
179
180
  let services;
180
- try { services = await repairServices({ homeDir, platform }); }
181
+ try { services = reservedService ? await repairServices({ homeDir, platform }) : { ok: true, changed: false, status: "service-budget-exhausted" }; }
181
182
  catch (error) { services = { ok: false, status: "service-repair-failed", lastError: error.message }; }
182
- if (reservedService && (services.status === "deferred-update-owner" || (services.ok && !services.changed))) progress.refund("services");
183
+ const serviceQueryUncertain = /^service-registration-query-failed/.test(services.lastError || "");
184
+ if (reservedService && (serviceQueryUncertain || services.status === "deferred-update-owner" || (services.ok && !services.changed))) progress.refund("services");
183
185
  const ready = (target = null, after = 0) => verifyReady({ homeDir, platform, target, after, now, sleep, health });
184
186
  const rememberReady = observed => {
185
187
  const active = observed?.current || read(path.join(root, "runtime", "current.json"));
@@ -225,6 +227,7 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
225
227
  transaction: { pid: transaction.pid, requestId: transaction.requestId, version: transaction.version } });
226
228
  };
227
229
  if (services.status === "deferred-update-owner") return status({ ...services, runtimeHealthy: false });
230
+ if (serviceQueryUncertain) return status({ ...services, runtimeHealthy: false });
228
231
  if (services.changed || !services.ok) {
229
232
  const observed = await ready();
230
233
  if (observed.ok) return proven({ ok: true, status: "current", version: observed.current?.version, repair: "services", lastSuccessAt: now() }, observed);
@@ -235,7 +238,7 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
235
238
  // Accepted commands are not recovery. Repeated failure advances even when
236
239
  // an inactive journal or unusable registration snapshot is still present.
237
240
  }
238
- if (/^(0|false|off|no)$/i.test(String(env.RELAY_AUTO_UPDATE || "")) || read(path.join(root, "recovery", "policy.json"))?.autoUpdate === false) return status({ ok: true, status: "disabled" });
241
+ const updatesEnabled = !/^(0|false|off|no)$/i.test(String(env.RELAY_AUTO_UPDATE || "")) && read(path.join(root, "recovery", "policy.json"))?.autoUpdate !== false;
239
242
  const downloads = path.join(root, "recovery", "downloads");
240
243
  let staged = null;
241
244
  let discoveryError = null;
@@ -243,18 +246,23 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
243
246
  let current = null;
244
247
  let runtimeResponsive = false;
245
248
  let runtimeVerified = false;
246
- try {
247
- sweepAbandonedDownloads(downloads, log);
248
- // Discovery still comes first, but an unreachable registry no longer blocks
249
- // the local rungs: a dead daemon with the right code on disk needs a restart,
250
- // not a network connection.
249
+ let discoveryAttempted = false;
250
+ const discoverDesired = async () => {
251
+ if (!updatesEnabled || discoveryAttempted) return;
252
+ discoveryAttempted = true;
251
253
  try { desiredVersion = await discoverImpl(channel); log(`discovered ${desiredVersion} on ${channel}`); }
252
254
  catch (error) { discoveryError = error; log(`discovery failed: ${error.message}`); }
255
+ };
256
+ try {
257
+ sweepAbandonedDownloads(downloads, log);
253
258
  current = read(path.join(root, "runtime", "current.json"));
254
259
  const heartbeat = read(path.join(root, "recovery", "daemon.json"));
255
260
  const heartbeatFresh = heartbeat?.at <= now() && now() - heartbeat.at < HEARTBEAT_MS;
256
261
  const installedIsDesired = current?.active === true;
257
262
  const live = installedIsDesired ? await health(current, { platform }) : null;
263
+ // Local repairs never wait behind registry timeouts. Healthy installations
264
+ // still check for new releases; unavailable ones exhaust retained repairs first.
265
+ if (!installedIsDesired || (live?.ok && heartbeatFresh)) await discoverDesired();
258
266
  if (live && platform === "darwin" && !services.ok) live.ok = false;
259
267
  let readiness = null;
260
268
  if (installedIsDesired && heartbeatFresh && heartbeat.version === current.version && live.ok) {
@@ -277,7 +285,9 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
277
285
  repairState.version = current.version;
278
286
  const sameTarget = previous?.version === current.version;
279
287
  repairState.restarts = progress.count(`restart:${current.packageRoot || current.version}`);
280
- repairState.staleSince = sameTarget && Number.isFinite(previous.staleSince) && previous.staleSince <= now() ? previous.staleSince : now();
288
+ const cadence = platform === "darwin" && read(path.join(root, "recovery", "scheduler.json"))?.intervalSeconds !== 60 ? 5 * 60_000 : CHECK_MS;
289
+ const consecutive = previous?.checkedAt <= now() && now() - previous.checkedAt <= cadence * 2;
290
+ repairState.staleSince = sameTarget && consecutive && Number.isFinite(previous.staleSince) && previous.staleSince <= now() ? previous.staleSince : now();
281
291
  }
282
292
  if (installedIsDesired && !(live.ok && heartbeatFresh && heartbeat.version === current.version)) {
283
293
  // The code on disk is the code we want; the problem is liveness. Repair in
@@ -377,6 +387,8 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
377
387
  progress.fail(error.message); log('local recovery failed: ' + error.message);
378
388
  }
379
389
  }
390
+ if (!updatesEnabled) return status({ ok: true, status: "disabled", runtimeHealthy: runtimeVerified, runtimeAvailable: runtimeResponsive });
391
+ await discoverDesired();
380
392
  if (discoveryError) throw discoveryError;
381
393
  let quarantine = policy.decision(channel, desiredVersion);
382
394
  if (quarantine.blocked && !runtimeResponsive) {
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ // A launchd job cannot reload itself without killing its installer. This small
3
+ // independent worker waits for the old controller, owns its exclusion locks,
4
+ // and restores the previous plist if the new schedule cannot be loaded.
5
+ const fs = require("node:fs"), path = require("node:path");
6
+ const { spawnSync } = require("node:child_process");
7
+ const io = require("./recovery-launcher.cjs");
8
+ async function handover({ homeDir, userId, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
9
+ run = (file, args) => spawnSync(file, args, { encoding: "utf8", timeout: 15_000, windowsHide: true }), attempts = 360 } = {}) {
10
+ const root = path.join(homeDir, ".relay", "recovery");
11
+ const label = "work.relay.companion.recovery", domain = `gui/${userId}`;
12
+ const plist = path.join(homeDir, "Library", "LaunchAgents", `${label}.plist`);
13
+ const previous = path.join(root, "scheduler-previous.plist");
14
+ const ok = r => !r?.error && r?.status === 0;
15
+ for (let attempt = 0; attempt < attempts; attempt++) {
16
+ if (require("./recovery-intent.cjs").stopped(homeDir)) return { ok: true, reason: "intentionally-stopped" };
17
+ let release, runner, transaction;
18
+ try {
19
+ release = io.acquireLauncherLock(root);
20
+ if (!release) continue;
21
+ runner = io.acquireCanonicalLock(path.join(root, "run.lock"));
22
+ transaction = io.acquireCanonicalLock(path.join(homeDir, ".relay", "runtime", "transaction.lock"));
23
+ if (require("./recovery-intent.cjs").stopped(homeDir)) return { ok: true, reason: "intentionally-stopped" };
24
+ // The old job is no longer running. Keep the old file until bootstrap is
25
+ // proven; an interrupted handover remains repairable by the app/installer.
26
+ const removed = run("/bin/launchctl", ["bootout", `${domain}/${label}`]);
27
+ if (!ok(removed)) throw Error("recovery-schedule-bootout-failed");
28
+ const started = run("/bin/launchctl", ["bootstrap", domain, plist]);
29
+ if (!ok(started)) {
30
+ if (fs.existsSync(previous)) {
31
+ require("./mac-registration-transaction.cjs").atomicFile(plist, fs.readFileSync(previous));
32
+ run("/bin/launchctl", ["bootstrap", domain, plist]);
33
+ }
34
+ throw Error("recovery-schedule-bootstrap-failed");
35
+ }
36
+ io.write(path.join(root, "scheduler.json"), { schema: 1, intervalSeconds: 60, at: Date.now() });
37
+ return { ok: true };
38
+ } catch (error) {
39
+ if (transaction) { io.write(path.join(root, "scheduler-error.json"), { at: Date.now(), error: error.message }); return { ok: false, reason: error.message }; }
40
+ } finally {
41
+ transaction?.release(); runner?.release(); release?.();
42
+ // Sleep also on contention, without retaining any mutation authority.
43
+ if (!transaction) await sleep(5000);
44
+ }
45
+ }
46
+ return { ok: false, reason: "recovery-schedule-handover-deadline" };
47
+ }
48
+ module.exports = { handover };
49
+ if (require.main === module) handover({ homeDir: process.argv[2], userId: Number(process.argv[3]) }).then(result => {
50
+ console.log(JSON.stringify(result));
51
+ // launchctl submit restarts nonzero exits; a failed migration must be bounded.
52
+ }).catch(error => { console.error(error.message); });
@@ -11,7 +11,7 @@
11
11
  // is a canonical runtime transaction currently owned by a live process?
12
12
  const fs = require("node:fs");
13
13
  const path = require("node:path");
14
- const { processAlive, nativeProcessIdentity } = require("./recovery-launcher.cjs");
14
+ const { processAlive, nativeProcessIdentity, liveLockParticipants } = require("./recovery-launcher.cjs");
15
15
 
16
16
  // An admitted update request older than this is debris from a worker that died
17
17
  // without writing its terminal state; the lock engine reclaims after owner death.
@@ -53,7 +53,7 @@ function inFlightTransaction({ homeDir, now = Date.now(), alive = processAlive,
53
53
  break;
54
54
  }
55
55
  if (request) return request;
56
- if (owner && liveOwner(owner, { alive, identity })) {
56
+ if (owner && (liveOwner(owner, { alive, identity }) || liveLockParticipants(path.join(root, "transaction.lock"), owner.nonce, { isProcessAlive: alive, processIdentity: identity }))) {
57
57
  return { source: "lock", pid: Number(owner.pid), requestId: owner.requestId || null, version: null, admittedAt: Number(owner.createdAt) || null };
58
58
  }
59
59
  return null;
@@ -77,6 +77,10 @@ function stableNodePath(execPath = process.execPath, {
77
77
  spawnImpl = spawnSync,
78
78
  } = {}) {
79
79
  if (!execPath) return execPath;
80
+ const contract = require("./node-contract.cjs");
81
+ if (contract.isElectronExecutable(execPath, { realpath: realpathSync })) {
82
+ return contract.resolveManagedNode({ node: null, run: spawnImpl, env });
83
+ }
80
84
  let realExec = execPath;
81
85
  try { realExec = realpathSync(execPath); } catch {}
82
86
  const volatile = VERSION_MANAGED_NODE_RE.test(execPath)
@@ -407,11 +407,23 @@ async function activateWindowsRuntimeServices(target, {
407
407
  }
408
408
 
409
409
  /** One entry point for "restart what is already installed" on every platform. */
410
- function restartInstalledRuntimeServices(target, { platform = process.platform, ...options } = {}) {
410
+ async function restartInstalledRuntimeServices(target, { platform = process.platform, ...options } = {}) {
411
+ if (!["darwin", "linux", "win32"].includes(platform)) return { ok: false, reason: "activation-platform-unsupported" };
411
412
  if (platform === "darwin") return require("./mac-service-recovery.cjs").restartMacRegisteredServices(target, options);
412
- if (platform === "linux") return activateLinuxRuntimeServices(target, { platform, ...options });
413
- if (platform === "win32") return activateWindowsRuntimeServices(target, { platform, ...options });
414
- return Promise.resolve({ ok: false, reason: "activation-platform-unsupported" });
413
+ let lease, releaseDrain;
414
+ const homeDir = options.homeDir || require("node:os").homedir();
415
+ try {
416
+ try { lease = require("./lifecycle-ownership.cjs").lifecycleOwnership({ homeDir }); }
417
+ catch { return { ok: false, reason: "deferred-update-owner" }; }
418
+ const current = JSON.parse(fs.readFileSync(path.join(homeDir, ".relay", "runtime", "current.json"), "utf8"));
419
+ if (!current?.active || current.packageRoot !== target.packageRoot) return { ok: false, reason: "runtime-changed" };
420
+ releaseDrain = await require("./update-activity.cjs").drainCalls({ homeDir });
421
+ lease.assert();
422
+ if (platform === "linux") return await activateLinuxRuntimeServices(target, { platform, ...options });
423
+ if (platform === "win32") return await activateWindowsRuntimeServices(target, { platform, ...options });
424
+ return { ok: false, reason: "activation-platform-unsupported" };
425
+ } catch (error) { return { ok: false, reason: error.message }; }
426
+ finally { releaseDrain?.(); lease?.release(); }
415
427
  }
416
428
 
417
429
  module.exports = {
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ const os = require("node:os"), path = require("node:path");
3
+ const { spawnSync } = require("node:child_process");
4
+ const io = require("./recovery-launcher.cjs");
5
+ const nodeContract = require("./node-contract.cjs");
6
+
7
+ // Registration restoration is part of the controller's canonical transaction.
8
+ // The UI may bootstrap the controller, but never constructs daemon/pill jobs.
9
+ async function repairServiceRegistrations({ homeDir = os.homedir(), platform = process.platform,
10
+ run = (file, args) => spawnSync(file, args, { encoding: "utf8", windowsHide: true, timeout: 10_000 }),
11
+ validate = require("./recovery-local.cjs").validateLocalRuntime,
12
+ execute = (...args) => require("./recovery-runner.cjs").execute(...args),
13
+ own = require("./lifecycle-ownership.cjs").lifecycleOwnership,
14
+ drain = require("./update-activity.cjs").drainCalls,
15
+ activate,
16
+ } = {}) {
17
+ if (platform === "darwin") {
18
+ const observed = await require("./mac-service-recovery.cjs").repairMacServiceRegistrations({ homeDir, platform, run });
19
+ if (observed.ok || observed.blocked) return observed;
20
+ // Invalid/missing plist bytes cannot be bootstrapped. A verified retained
21
+ // runtime can rebuild them under the same ownership contract below.
22
+ } else if (["win32", "linux"].includes(platform)) {
23
+ let missing = false;
24
+ for (const role of ["Daemon", "Pill"]) {
25
+ const result = platform === "win32"
26
+ ? run("schtasks.exe", ["/Query", "/TN", `Relay Companion ${role}`])
27
+ : run("systemctl", ["--user", "show", "--property=LoadState", "--value", `work.relay.companion${role === "Pill" ? ".pill" : ""}.service`]);
28
+ const text = String(result?.stdout || "").trim();
29
+ const present = !result?.error && result.status === 0 && (platform === "win32" || text === "loaded");
30
+ if (present) continue;
31
+ const absent = !result?.error && (platform === "linux" ? text === "not-found" : /cannot find|does not exist|not found/i.test(String(result?.stderr || result?.stdout || "")));
32
+ if (!absent) return { ok: false, changed: false, blocked: true, status: "service-repair-failed", lastError: "service-registration-query-failed" };
33
+ missing = true;
34
+ }
35
+ if (!missing) return { ok: true, changed: false, status: "services-present" };
36
+ } else return { ok: true, changed: false, status: "unsupported" };
37
+
38
+ let lease, releaseDrain;
39
+ try {
40
+ try { lease = own({ homeDir }); }
41
+ catch { return { ok: true, changed: false, status: "deferred-update-owner" }; }
42
+ const target = io.read(path.join(homeDir, ".relay", "runtime", "current.json"));
43
+ if (!target?.active || !validate(target, { platform })) return { ok: false, changed: false, status: "service-repair-failed", lastError: "no-verified-registration-source" };
44
+ const node = nodeContract.resolveManagedNode({ homeDir, node: target.node, run });
45
+ releaseDrain = await drain({ homeDir });
46
+ lease.assert();
47
+ await execute(node, path.join(target.packageRoot, "bin", "relay.js"), ["repair-runtime", "--no-restart"],
48
+ { env: nodeContract.nodeEnvironment(lease.env), timeoutMs: 90_000 });
49
+ lease.assert();
50
+ const health = require("./runtime-health.cjs");
51
+ const activateRuntime = activate || (platform === "darwin" ? health.activateMacRuntimeServices
52
+ : platform === "win32" ? health.activateWindowsRuntimeServices : health.activateLinuxRuntimeServices);
53
+ const result = await activateRuntime(target, { homeDir, platform });
54
+ return { ok: result.ok, changed: true, status: result.ok ? "services-restored" : "service-repair-failed", lastError: result.reason };
55
+ } catch (error) { return { ok: false, changed: false, status: "service-repair-failed", lastError: error.message }; }
56
+ finally { releaseDrain?.(); lease?.release(); }
57
+ }
58
+ module.exports = { repairServiceRegistrations };
@@ -22,6 +22,7 @@ function updateLogStdio(platform = process.platform, homeDir = os.homedir()) {
22
22
  }
23
23
 
24
24
  if (require.main === module) {
25
+ if (process.versions.electron) throw Error("Relay update workers require the verified Node runtime, not Electron");
25
26
  const [entry, mode, payload] = process.argv.slice(2);
26
27
  if (!entry || mode !== "--worker" || !payload) throw Error("Missing update worker arguments");
27
28
  const { stdio, logPath } = updateLogStdio();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.552",
3
+ "version": "0.1.554",
4
4
  "description": "Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.",
5
5
  "homepage": "https://sendrelays.com/get-started",
6
6
  "repository": {