replica-bridge 0.3.17 → 0.3.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replica-bridge",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
4
4
  "description": "Lets the ChatReplica iPhone app use Codex on this computer through a relay, end-to-end encrypted.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -216,7 +216,7 @@ WantedBy=default.target
216
216
  function serviceEnv() {
217
217
  const env = {};
218
218
  for (const [key, value] of Object.entries(process.env)) {
219
- if (value !== undefined && (key === "PATH" || key === "Path" || /^(CODEX_|REPLICA_|RELAY_ENV$|AGENS_BACKEND_PORT$)/.test(key)) && key !== "REPLICA_BRIDGE_LOG") env[key] = value;
219
+ if (value !== undefined && (key === "PATH" || key === "Path" || /^(CODEX_|REPLICA_|RELAY_ENV$|AGENS_BACKEND_PORT$|AGENS_WORKSPACE_ROOT$)/.test(key)) && key !== "REPLICA_BRIDGE_LOG") env[key] = value;
220
220
  }
221
221
  for (const key of ["PATH", "Path"]) if (env[key]) env[key] = servicePATH(env[key]);
222
222
  env.REPLICA_BRIDGE_SERVICE = "1";
@@ -83,12 +83,19 @@ function startBridge() {
83
83
  return service;
84
84
  }
85
85
 
86
- /** Warns, without stopping, when Codex does not answer the way the bridge will run it (through the login shell). */
86
+ /**
87
+ * Warns, without stopping, when Codex does not answer the way the bridge will run it (through the login shell).
88
+ * The shell is interactive (-i) so it reads the rc file where people put Codex on PATH, and an interactive shell that
89
+ * shares this terminal takes job control of it: zsh, and some bash set-ups, left the terminal to their own process
90
+ * group, so the next read of the terminal (the [Y/n] question, or "Allow?") stopped this process ("[1]+ Stopped
91
+ * npx replica-bridge", 2026-11-08) and with it the relay connection. `detached` starts it in its own session, with no
92
+ * terminal to take; it still reads the rc file.
93
+ */
87
94
  function checkCodex() {
88
95
  const codex = values.codex ?? "codex";
89
96
  const loginShell = process.platform !== "win32" && !values["no-login-shell"] && process.env.SHELL;
90
97
  const result = loginShell
91
- ? spawnCommandSync(process.env.SHELL , ["-lic", `"$0" --version`, codex], { encoding: "utf8", timeout: 15_000 })
98
+ ? spawnCommandSync(process.env.SHELL , ["-lic", `"$0" --version`, codex], { encoding: "utf8", timeout: 15_000, detached: true })
92
99
  : spawnCommandSync(codex, ["--version"], { encoding: "utf8", timeout: 15_000 });
93
100
  if (result.status === 0) return;
94
101
  console.warn(`Codex did not answer "${codex} --version". Install it (npm install -g @openai/codex) and sign in (codex login); the phone cannot use this computer until then.`);
@@ -111,7 +118,55 @@ async function wantBackground() {
111
118
  return ask("Keep it running in the background and start it at login, so you can close this terminal? [Y/n] ");
112
119
  }
113
120
 
114
- function install() {
121
+ /** How long to wait for the relay socket before saying it has not connected; a working network takes well under a second. */
122
+ const RELAY_WAIT_MS = Number(process.env.REPLICA_BRIDGE_RELAY_WAIT_MS ?? 10_000);
123
+
124
+ /**
125
+ * Said when the bridge has not reached the relay. Enrolling and pairing are plain HTTPS requests, so they work on a
126
+ * network that blocks the WebSocket; then the phone pairs and shows the computer offline with no hint why
127
+ * (2026-11-08). The bridge keeps retrying, so this is a warning, not a stop.
128
+ */
129
+ function relayWarning(relay , problem ) {
130
+ const socket = relay.replace(/^http/, "ws");
131
+ return [
132
+ `This computer has not connected to the relay yet${problem ? `: ${problem}` : ` (no answer within ${RELAY_WAIT_MS / 1000} s)`}.`,
133
+ "Until it does, the phone shows this computer offline. Pairing still works, since it uses plain HTTPS, and the bridge keeps trying.",
134
+ `A proxy or firewall that lets HTTPS through but blocks WebSocket connections to ${socket} causes exactly this.`,
135
+ ].join("\n");
136
+ }
137
+
138
+ /** Waits for this process's bridge to reach the relay and warns when it does not. */
139
+ async function checkRelay(bridge , relay ) {
140
+ if (await bridge.waitForRelay(RELAY_WAIT_MS)) return true;
141
+ console.warn(relayWarning(relay, bridge.relayProblem));
142
+ return false;
143
+ }
144
+
145
+ /** The background service's log past `offset` bytes, or "" before it exists. */
146
+ function logSince(offset ) {
147
+ try { return fs.readFileSync(logPath()).subarray(offset).toString("utf8"); } catch { return ""; }
148
+ }
149
+
150
+ function logSize() {
151
+ try { return fs.statSync(logPath()).size; } catch { return 0; }
152
+ }
153
+
154
+ /** The background service is another process: its log is the only place that says whether it reached the relay. */
155
+ async function checkServiceRelay(offset , relay ) {
156
+ for (let waited = 0; waited < RELAY_WAIT_MS; waited += 250) {
157
+ if (/ connected to relay /.test(logSince(offset))) return;
158
+ await new Promise((resolve) => setTimeout(resolve, 250));
159
+ }
160
+ const text = logSince(offset);
161
+ if (/ connected to relay /.test(text)) return;
162
+ const problems = [...text.matchAll(/relay connection error: (.*)|relay refused the host connection: (HTTP \d+)/g)];
163
+ const last = problems.at(-1);
164
+ console.warn(relayWarning(relay, last ? (last[1] ?? `${last[2]} instead of a WebSocket`) : null));
165
+ console.warn(`The background service keeps trying; its log is ${logPath()}.`);
166
+ }
167
+
168
+ async function install() {
169
+ const offset = logSize();
115
170
  const plan = installService(startArgs(), console.log);
116
171
  // The service writes its pid once it has started; `status` right after the install said "not running" before that.
117
172
  for (let waited = 0; !runningPid() && waited < 10_000; waited += 200) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
@@ -122,6 +177,7 @@ function install() {
122
177
  if (process.platform === "linux" && !fs.existsSync(`/var/lib/systemd/linger/${user}`)) {
123
178
  console.log(`To start it when the computer boots, before anyone logs in: sudo loginctl enable-linger ${user}`);
124
179
  }
180
+ await checkServiceRelay(offset, loadState().relay);
125
181
  }
126
182
 
127
183
  async function pairOrExplain() {
@@ -156,19 +212,20 @@ async function main() {
156
212
  // The background service serves the phone: restart it onto a new set-up or a newer version, then pair.
157
213
  if (moved) console.log("Restarting the background service with the new set-up.");
158
214
  else if (installed.version !== PACKAGE_VERSION) console.log(`Updating the background service from ${installed.version} to ${PACKAGE_VERSION}.`);
159
- if (moved || values.install || installed.version !== PACKAGE_VERSION) install();
215
+ if (moved || values.install || installed.version !== PACKAGE_VERSION) await install();
160
216
  if (moved || values.pair || !state.devices.length) await pairOrExplain();
161
217
  else status();
162
218
  break;
163
219
  }
164
220
  if (runningPid()) throw new Error(`a bridge already runs for this computer (pid ${runningPid()}); stop it first`);
165
221
  let bridge = startBridge();
222
+ await checkRelay(bridge, state.relay);
166
223
  if (values.pair || !state.devices.length) await pairOrExplain();
167
224
  if (loadState().devices.length && await wantBackground()) {
168
225
  // One bridge per computer: the relay keeps only the newest connection for it.
169
226
  bridge.stop();
170
227
  try {
171
- install();
228
+ await install();
172
229
  process.exit(0);
173
230
  } catch (error) {
174
231
  console.error(`replica-bridge: could not set up the background service: ${(error ).message}`);
@@ -177,11 +234,12 @@ async function main() {
177
234
  }
178
235
  for (const signal of ["SIGINT", "SIGTERM"] ) process.on(signal, () => { bridge.stop(); process.exit(0); });
179
236
  console.log(`Running in this terminal: paired phones can use Codex on ${state.hostName} until it closes (Ctrl-C stops it).`);
237
+ if (!(await bridge.waitForRelay(RELAY_WAIT_MS))) console.warn(`Still not connected to the relay${bridge.relayProblem ? ` (${bridge.relayProblem})` : ""}: the phone shows ${state.hostName} offline until it is.`);
180
238
  break;
181
239
  }
182
240
  case "install":
183
241
  await ensureRelay(values.relay, console.log, values.name);
184
- install();
242
+ await install();
185
243
  break;
186
244
  case "uninstall": {
187
245
  const plan = uninstallService();
@@ -196,7 +254,7 @@ async function main() {
196
254
  const state = await enroll(relay, { name: values.name, publicRelay: values["public-url"] });
197
255
  console.log(`Set up ${state.hostName} as ${state.hostId}. Next: "npx replica-bridge".`);
198
256
  console.log("Nobody owns it yet: the first phone that scans its QR code and is approved here claims it.");
199
- if (readServiceInfo()) { console.log("Restarting the background service with the new set-up."); install(); }
257
+ if (readServiceInfo()) { console.log("Restarting the background service with the new set-up."); await install(); }
200
258
  break;
201
259
  }
202
260
  case "config": {
@@ -209,7 +267,7 @@ async function main() {
209
267
  case "pair":
210
268
  if ((await ensureRelay(values.relay, console.log, values.name)).moved && readServiceInfo()) {
211
269
  console.log("Restarting the background service with the new set-up.");
212
- install();
270
+ await install();
213
271
  }
214
272
  await pair({ confirm: values.confirm, yes: values.yes, timeoutSeconds: Number(values.timeout), pngPath: values.png });
215
273
  if (!runningPid()) console.log(`Nothing serves this computer right now: run "npx replica-bridge" so the phone can connect.`);
@@ -15,6 +15,7 @@ import {
15
15
 
16
16
  import { relaySocketURL } from "../http.js";
17
17
  import { BoardReader } from "./board.js";
18
+ import { readWork, workDirectories } from "./work.js";
18
19
  import { killCommand, spawnCommand } from "./command.js";
19
20
  import { hostKeyPair, loadState } from "./state.js";
20
21
  import { annotateWriterError } from "./writer.js";
@@ -57,6 +58,8 @@ import { annotateWriterError } from "./writer.js";
57
58
 
58
59
 
59
60
 
61
+
62
+
60
63
 
61
64
 
62
65
 
@@ -121,12 +124,42 @@ export class BridgeService {
121
124
 
122
125
  get streamCount() { return this.streams.size; }
123
126
 
127
+ /** The relay socket is open: phones see this computer online. */
128
+ get relayConnected() { return this.relay?.readyState === WebSocket.OPEN; }
129
+
130
+ /** Why the last attempt to reach the relay failed, while it has not connected since. */
131
+ get relayProblem() { return this.lastRelayProblem; }
132
+
133
+ lastRelayProblem = null;
134
+ relayWaiters = new Set ();
135
+
136
+ /**
137
+ * Waits up to `ms` for the relay socket to open. Pairing goes over plain HTTPS, so a network that lets HTTPS through
138
+ * but not the WebSocket (2026-11-08: two computers paired, never once came online, and the phone showed both
139
+ * offline) pairs fine and leaves nothing for the phone to reach; the caller says so instead of looking finished.
140
+ */
141
+ async waitForRelay(ms ) {
142
+ if (this.relayConnected) return true;
143
+ await new Promise ((resolve) => {
144
+ const done = () => { clearTimeout(timer); this.relayWaiters.delete(done); resolve(); };
145
+ const timer = setTimeout(done, ms);
146
+ this.relayWaiters.add(done);
147
+ });
148
+ return this.relayConnected;
149
+ }
150
+
151
+ /** Only an open socket ends the wait early: a first attempt that fails often succeeds on the retry a second later. */
152
+ relayChanged() {
153
+ for (const waiter of [...this.relayWaiters]) waiter();
154
+ }
155
+
124
156
  // MARK: - Relay connection
125
157
 
126
158
  connectRelay() {
127
159
  const state = loadState();
128
160
  const ws = new WebSocket(relaySocketURL(state.relay, "/v1/host"), { headers: { authorization: `Bearer ${state.hostToken}` }, maxPayload: MAX_FRAME_BYTES + 4096, perMessageDeflate: false });
129
161
  this.relay = ws;
162
+ let refused = false;
130
163
  // A laptop that sleeps or changes networks keeps a socket the relay dropped long ago, and nothing here would
131
164
  // notice until the bridge sent something: the computer stayed offline for the phones. A missed pong ends it.
132
165
  let answered = true;
@@ -138,7 +171,14 @@ export class BridgeService {
138
171
  }, (this.options.relayPingSeconds ?? 30) * 1000);
139
172
  ws.on("pong", () => { answered = true; });
140
173
  ws.on("ping", () => { answered = true; });
141
- ws.on("open", () => { answered = true; this.retry = 0; this.log(`connected to relay ${state.relay} as ${state.hostId}`); this.options.onRelayConnected?.(true); });
174
+ ws.on("open", () => {
175
+ answered = true;
176
+ this.retry = 0;
177
+ this.lastRelayProblem = null;
178
+ this.log(`connected to relay ${state.relay} as ${state.hostId}`);
179
+ this.options.onRelayConnected?.(true);
180
+ this.relayChanged();
181
+ });
142
182
  ws.on("message", (data ) => {
143
183
  let envelope ;
144
184
  try { envelope = JSON.parse(data.toString()); } catch { return; }
@@ -149,10 +189,16 @@ export class BridgeService {
149
189
  // process ran out of work and exited 0, which the background services read as "replaced" and left stopped.
150
190
  // Ending the socket goes through close and the usual backoff instead.
151
191
  ws.on("unexpected-response", (_req, res) => {
192
+ refused = true;
193
+ this.lastRelayProblem = `HTTP ${res.statusCode} instead of a WebSocket`;
152
194
  this.log(`relay refused the host connection: HTTP ${res.statusCode}; retrying`);
153
195
  ws.terminate();
154
196
  });
155
- ws.on("error", (error) => this.log(`relay connection error: ${error.message}`));
197
+ ws.on("error", (error) => {
198
+ // Ending a refused socket also reports "closed before the connection was established"; the status says more.
199
+ if (!refused) this.lastRelayProblem = describeSocketError(error);
200
+ this.log(`relay connection error: ${this.lastRelayProblem}`);
201
+ });
156
202
  ws.on("close", (code) => {
157
203
  clearInterval(ping);
158
204
  if (ws !== this.relay) return; // a socket this bridge already replaced itself
@@ -273,6 +319,9 @@ export class BridgeService {
273
319
  void this.board.post(message.path, message.body).then((answer) => this.sendInner(conn, { t: "board/res", id: message.id, ...answer }));
274
320
  } else if (message.t === "board/sub") {
275
321
  conn.boardUnsubscribe ??= this.board.subscribe((event) => this.sendInner(conn, event), message.after);
322
+ } else if (message.t === "work/get") {
323
+ // Read when asked, so a new main conversation started on the desktop is what the next question hears.
324
+ this.sendInner(conn, { t: "work/res", id: message.id, ...readWork(this.options.workDirectories ?? workDirectories()) });
276
325
  } else if (message.t === "board/unsub") {
277
326
  conn.boardUnsubscribe?.();
278
327
  conn.boardUnsubscribe = null;
@@ -420,9 +469,10 @@ export class BridgeService {
420
469
  }
421
470
 
422
471
  /** Runs a command to completion; true when it exits 0 within 30 s. Output is dropped (an rc file may print). */
423
- run(command , args ) {
472
+ /** `detached` for an interactive login shell, which would otherwise take the terminal of a bridge run in one (main.ts checkCodex). */
473
+ run(command , args , detached = false) {
424
474
  return new Promise((resolve, reject) => {
425
- const child = spawnCommand(command, args, { stdio: "ignore" });
475
+ const child = spawnCommand(command, args, { stdio: "ignore", detached });
426
476
  const timer = setTimeout(() => { killCommand(child); resolve(false); }, 30_000);
427
477
  child.once("error", (error) => { clearTimeout(timer); reject(error); });
428
478
  child.once("exit", (code) => { clearTimeout(timer); resolve(code === 0); });
@@ -457,7 +507,7 @@ export class BridgeService {
457
507
  // (a SHELL from Git Bash is a POSIX path Node cannot run), so Codex is asked directly there.
458
508
  const shell = process.platform === "win32" ? null : process.env.SHELL || os.userInfo().shell;
459
509
  const started = this.options.loginShell !== false && shell
460
- ? await this.run(shell, ["-lic", 'exec "$0" app-server daemon start', codex]).catch(() => false)
510
+ ? await this.run(shell, ["-lic", 'exec "$0" app-server daemon start', codex], true).catch(() => false)
461
511
  : false;
462
512
  if (!started && !(await this.run(codex, ["app-server", "daemon", "start"]).catch(() => false))) {
463
513
  throw new Error("codex app-server daemon start failed");
@@ -510,3 +560,13 @@ export function fitLine(line , limit = RELAY_REPLY_LIMIT)
510
560
  if (id === undefined || id === null) return null;
511
561
  return JSON.stringify({ id, error: { code: -32010, message: `the reply is ${bytes} bytes, more than the relay carries (${limit})`, data: { replicaTooLarge: { bytes, limit } } } });
512
562
  }
563
+
564
+ /**
565
+ * An error's message, or its code when Node leaves the message empty: a refused connection tried on both IPv4 and
566
+ * IPv6 is an AggregateError with message "" and the reasons in `errors`.
567
+ */
568
+ export function describeSocketError(error ) {
569
+ const parts = error instanceof AggregateError ? error.errors : [error];
570
+ const text = parts.map((e ) => e?.message || e?.code || String(e)).filter(Boolean);
571
+ return [...new Set(text)].join("; ") || error.name;
572
+ }
@@ -0,0 +1,46 @@
1
+ // Agens Work's main conversation (PROTOCOL.md "Work"), for agens-mobile's Work row. The Agens desktop keeps it in
2
+ // agens-work.json in the Work workspace (AgensWorkDev agens/extensions/work-chat/main.cjs): AGENS_WORKSPACE_ROOT, else
3
+ // ~/.agens-work/workspaces/assistant, and the first of those with a record is the one in use. The bridge only reads
4
+ // that file when a phone asks, so it needs no hook into the desktop, and a computer without Agens Work simply says so.
5
+ // Only whether Work is on and the conversation's id leave this computer, not the paths or anything else in the record.
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+
10
+
11
+ export const WORK_RECORD = "agens-work.json";
12
+ /** A Codex thread id (a UUID); anything else in the record is not passed on. */
13
+ const THREAD_ID = /^[A-Za-z0-9-]{1,64}$/;
14
+
15
+ /**
16
+ * What the phone hears (WorkAnswer): on with the main conversation, or off and why. `none`: Agens Work was never agreed
17
+ * to here; `off`: switched off in its settings; `preparing`: agreed, but the main conversation is not made yet.
18
+ */
19
+
20
+ /** Where the desktop looks for the record, in its order (main.cjs chooseWorkspace). */
21
+ export function workDirectories(env = process.env, home = os.homedir()) {
22
+ const legacy = path.join(home, ".agens-work", "workspaces", "assistant");
23
+ return [...new Set([env.AGENS_WORKSPACE_ROOT || legacy, legacy])];
24
+ }
25
+
26
+ function readRecord(directory ) {
27
+ try {
28
+ const record = JSON.parse(fs.readFileSync(path.join(directory, WORK_RECORD), "utf8"));
29
+ return record && typeof record === "object" && record.schema === 1 ? record : null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /** Reads the record now, as the desktop would see it. */
36
+ export function readWork(directories = workDirectories()) {
37
+ for (const directory of directories) {
38
+ const record = readRecord(directory);
39
+ if (!record) continue;
40
+ // A record without `enabled` predates the desktop's switch, and means on (main.cjs enabled()).
41
+ if (record.enabled === false) return { on: false, reason: "off" };
42
+ const threadId = record.mainThreadId;
43
+ return typeof threadId === "string" && THREAD_ID.test(threadId) ? { on: true, threadId } : { on: false, reason: "preparing" };
44
+ }
45
+ return { on: false, reason: "none" };
46
+ }
package/src/protocol.js CHANGED
@@ -1,4 +1,4 @@
1
- // Shared protocol for replica-relay, replica-bridge and the test phone (Relay/PROTOCOL.md).
1
+ // Shared protocol for replica-relay, replica-bridge and the test phone (PROTOCOL.md).
2
2
  // Everything here is plain Node: X25519, HKDF-SHA256, ChaCha20-Poly1305 and HMAC-SHA256 match CryptoKit's
3
3
  // Curve25519.KeyAgreement, HKDF<SHA256>, ChaChaPoly and HMAC<SHA256>, so the Swift side can follow the same steps.
4
4
  import crypto from "node:crypto";
@@ -243,7 +243,12 @@ export class FrameCipher {
243
243
 
244
244
 
245
245
 
246
-
246
+
247
+
248
+
249
+
250
+ /** Agens Work's main conversation on this computer, or why there is none (PROTOCOL.md "Work", 0.3.19). */
251
+
247
252
 
248
253
  /** A read or write of the optional board: the backend's status and body, or why there is none (PROTOCOL.md "看板"). */
249
254
 
@@ -337,6 +342,8 @@ export const CloseCode = {
337
342
  replaced: 4409,
338
343
  protocolError: 4400,
339
344
  streamGone: 4410,
345
+ /** A desktop's computer used up its daily traffic; it may connect again after midnight UTC. */
346
+ quota: 4429,
340
347
  } ;
341
348
 
342
349
  /**
@@ -1,4 +1,4 @@
1
- // Reads ~/.config/replica-relay/relay.env (Relay/deploy/relay.env.example). Real environment variables win over
1
+ // Reads ~/.config/replica-relay/relay.env (deploy/relay.env.example). Real environment variables win over
2
2
  // the file, so a systemd Environment= line or a one-off `RELAY_PORT=… node …` still works.
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
@@ -33,6 +33,13 @@ export function readEnvFile(file ) {
33
33
 
34
34
 
35
35
 
36
+
37
+
38
+
39
+
40
+
41
+
42
+
36
43
 
37
44
 
38
45
 
@@ -77,6 +84,18 @@ export function allowedIdsReader(config
77
84
  };
78
85
  }
79
86
 
87
+ function desktopPairingMode(value ) {
88
+ if (value === "open" || value === "owned" || value === "off") return value;
89
+ throw new Error(`RELAY_DESKTOP_PAIRING takes open, owned or off, not "${value}"`);
90
+ }
91
+
92
+ function positiveNumber(name , value ) {
93
+ if (value === null) return null;
94
+ const number = Number(value);
95
+ if (!(number > 0)) throw new Error(`${name} takes a positive number, not "${value}"`);
96
+ return number;
97
+ }
98
+
80
99
  const expandHome = (value ) => (value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value);
81
100
 
82
101
  export function loadRelayConfig(envPath = defaultEnvPath()) {
@@ -87,11 +106,16 @@ export function loadRelayConfig(envPath = defaultEnvPath()) {
87
106
  };
88
107
  const origin = get("RELAY_PUBLIC_ORIGIN");
89
108
  if (origin && !/^https?:\/\/[^/]+\/?$/.test(origin)) throw new Error(`RELAY_PUBLIC_ORIGIN must look like https://host[:port], got ${origin}`);
109
+ const dailyMiB = positiveNumber("RELAY_DESKTOP_DAILY_MIB", get("RELAY_DESKTOP_DAILY_MIB"));
90
110
  return {
91
111
  listen: get("RELAY_LISTEN") ?? "127.0.0.1",
92
112
  port: Number(get("RELAY_PORT") ?? 8790),
93
113
  publicOrigin: origin?.replace(/\/$/, "") ?? null,
94
114
  trustProxy: get("RELAY_TRUST_PROXY") === "1",
115
+ clientIPHeader: get("RELAY_CLIENT_IP_HEADER"),
116
+ desktopPairing: desktopPairingMode(get("RELAY_DESKTOP_PAIRING") ?? "open"),
117
+ anonymousIdleDays: positiveNumber("RELAY_ANONYMOUS_IDLE_DAYS", get("RELAY_ANONYMOUS_IDLE_DAYS")),
118
+ desktopBytesPerHostPerDay: dailyMiB === null ? null : dailyMiB * 1024 * 1024,
95
119
  dataDir: expandHome(get("RELAY_DATA_DIR") ?? "~/.local/share/replica-relay"),
96
120
  devLogin: get("RELAY_DEV_LOGIN") === "1",
97
121
  githubClientId: get("GITHUB_CLIENT_ID"),