replica-bridge 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,8 @@
1
- # Temporary Holding Version
1
+ # replica-bridge
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Lets the ChatReplica iPhone app use Codex on this computer. Needs Codex installed and signed in (`codex login`).
4
+
5
+ npx replica-bridge
6
+
7
+ The first run sets this computer up with the relay, shows a QR code to scan in the app (远程 › 添加连接 › 配对新设备),
8
+ and keeps running: paired phones can use Codex here while it does. `npx replica-bridge --help` lists the other commands.
package/package.json CHANGED
@@ -1,6 +1,17 @@
1
1
  {
2
2
  "name": "replica-bridge",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Lets the ChatReplica iPhone app use Codex on this computer through a relay, end-to-end encrypted.",
5
+ "type": "module",
6
+ "bin": {
7
+ "replica-bridge": "src/bridge/main.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=20"
11
+ },
12
+ "dependencies": {
13
+ "qrcode": "1.5.4",
14
+ "ws": "8.18.3"
15
+ },
16
+ "license": "UNLICENSED"
17
+ }
@@ -0,0 +1,39 @@
1
+ // First run: make this computer's key pair and enroll it, anonymously, with a relay.
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import { b64url, generateKeyPair } from "../protocol.js";
5
+ import { relayRequest } from "../http.js";
6
+ import { defaultEnvPath, loadRelayConfig } from "../relay/config.js";
7
+ import { loadState, saveState } from "./state.js";
8
+
9
+
10
+ /** The project's public relay, which the app also signs in to by default (Sources/Core/RelayPairing.swift). */
11
+ export const PUBLIC_RELAY = "https://agens.office-cli.onl";
12
+
13
+ /** With no --relay: REPLICA_RELAY, else the relay on this machine over loopback, else the public relay. */
14
+ export function defaultRelayURL() {
15
+ if (process.env.REPLICA_RELAY) return process.env.REPLICA_RELAY;
16
+ return fs.existsSync(defaultEnvPath()) ? `http://127.0.0.1:${loadRelayConfig().port}` : PUBLIC_RELAY;
17
+ }
18
+
19
+ export async function enroll(relay , options = {}) {
20
+ const keys = generateKeyPair();
21
+ const hostName = options.name ?? os.hostname();
22
+ const { hostId, hostToken } = await relayRequest (relay, "POST", "/v1/hosts/enroll", { body: { publicKey: b64url(keys.publicKey), name: hostName } });
23
+ const state = { relay, publicRelay: options.publicRelay, hostId, hostToken, hostName, hostPrivateKey: b64url(keys.privateKey), hostPublicKey: b64url(keys.publicKey), devices: [] };
24
+ saveState(state);
25
+ return state;
26
+ }
27
+
28
+ /** The saved state, enrolling first if this computer never has. */
29
+ export async function ensureEnrolled(relay , say , name ) {
30
+ try {
31
+ return loadState();
32
+ } catch (error) {
33
+ if (!/not set up yet/.test((error ).message)) throw error;
34
+ }
35
+ const url = relay ?? defaultRelayURL();
36
+ const state = await enroll(url, { name });
37
+ say(`Set up ${state.hostName} with ${url} (${state.hostId}).`);
38
+ return state;
39
+ }
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ // replica-bridge: runs on the computer that has Codex. See docs/RELAY.md.
3
+ // The first pair or start sets the computer up by itself (anonymous enrollment); init does it explicitly.
4
+ // (no command) [--relay <url>] [--name <name>] [--pair] [--codex <path>]
5
+ // what `npx replica-bridge` runs: set up if needed, keep the bridge running, and show a QR code while no
6
+ // phone is paired yet (or with --pair)
7
+ // init [--relay <url>] [--public-url <url>] [--name <name>]
8
+ // config [--public-url <url>]
9
+ // pair [--relay <url>] [--confirm] [--yes] [--png <file>] [--timeout <seconds>]
10
+ // start [--codex <path>] [--stream-ttl <seconds>] [--no-login-shell]
11
+ // devices
12
+ // revoke <deviceId>
13
+ import { spawnSync } from "node:child_process";
14
+ import { parseArgs } from "node:util";
15
+ import { relayRequest } from "../http.js";
16
+ import { defaultRelayURL, enroll, ensureEnrolled } from "./enroll.js";
17
+ import { pair, publicRelayURL } from "./pair.js";
18
+ import { BridgeService } from "./service.js";
19
+ import { loadState, saveState } from "./state.js";
20
+
21
+ // A leading option means no command: `replica-bridge --pair` is the default command with --pair.
22
+ const argv = process.argv.slice(2);
23
+ const [command, ...rest] = argv[0]?.startsWith("-") ? [undefined, ...argv] : argv;
24
+ const { values, positionals } = parseArgs({
25
+ args: rest,
26
+ allowPositionals: true,
27
+ options: {
28
+ relay: { type: "string" },
29
+ "public-url": { type: "string" },
30
+ name: { type: "string" },
31
+ confirm: { type: "boolean", default: false },
32
+ yes: { type: "boolean", default: false },
33
+ png: { type: "string" },
34
+ timeout: { type: "string", default: "600" },
35
+ pair: { type: "boolean", default: false },
36
+ help: { type: "boolean", short: "h", default: false },
37
+ codex: { type: "string" },
38
+ "stream-ttl": { type: "string", default: "1800" },
39
+ "no-login-shell": { type: "boolean", default: false },
40
+ },
41
+ });
42
+
43
+ const usage = `usage: replica-bridge [command] (docs/RELAY.md)
44
+ (none) set up, keep running, and show a QR code until a phone is paired (--pair shows one anyway)
45
+ pair show a QR code for another phone
46
+ start keep running without showing a QR code
47
+ init set up only (--relay <url>, --name <name>)
48
+ config show the relay, this computer and its owner
49
+ devices list paired phones
50
+ revoke <id> remove a phone
51
+ The relay is ${defaultRelayURL()} unless --relay or REPLICA_RELAY says otherwise.`;
52
+
53
+ function startService() {
54
+ const service = new BridgeService({ codexPath: values.codex, loginShell: !values["no-login-shell"], streamTTLSeconds: Number(values["stream-ttl"]) });
55
+ service.start();
56
+ for (const signal of ["SIGINT", "SIGTERM"] ) process.on(signal, () => { service.stop(); process.exit(0); });
57
+ }
58
+
59
+ /** Warns, without stopping, when Codex does not answer the way the bridge will run it (through the login shell). */
60
+ function checkCodex() {
61
+ const codex = values.codex ?? "codex";
62
+ const loginShell = !values["no-login-shell"] && process.env.SHELL;
63
+ const result = loginShell
64
+ ? spawnSync(process.env.SHELL , ["-lic", `"$0" --version`, codex], { encoding: "utf8", timeout: 15_000 })
65
+ : spawnSync(codex, ["--version"], { encoding: "utf8", timeout: 15_000 });
66
+ if (result.status === 0) return;
67
+ 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.`);
68
+ }
69
+
70
+ async function main() {
71
+ if (values.help || command === "help") { console.log(usage); return; }
72
+ switch (command) {
73
+ case undefined:
74
+ case "up": {
75
+ checkCodex();
76
+ const state = await ensureEnrolled(values.relay, console.log, values.name);
77
+ startService();
78
+ if (values.pair || !state.devices.length) {
79
+ try {
80
+ await pair({ confirm: values.confirm, yes: values.yes, timeoutSeconds: Number(values.timeout), pngPath: values.png });
81
+ } catch (error) {
82
+ console.error(`replica-bridge: ${(error ).message}. Pair later with "replica-bridge pair".`);
83
+ }
84
+ }
85
+ console.log(`Running: paired phones can use Codex on ${state.hostName} while this keeps running (Ctrl-C stops it).`);
86
+ break;
87
+ }
88
+ case "init": {
89
+ const relay = values.relay ?? defaultRelayURL();
90
+ const state = await enroll(relay, { name: values.name, publicRelay: values["public-url"] });
91
+ console.log(`Set up ${state.hostName} as ${state.hostId}. Next: "replica-bridge start" (keep it running), then "replica-bridge pair".`);
92
+ console.log("Nobody owns it yet: the first phone that scans its QR code and is approved here claims it.");
93
+ break;
94
+ }
95
+ case "config": {
96
+ const state = loadState();
97
+ if (values["public-url"] !== undefined) state.publicRelay = values["public-url"] || undefined;
98
+ saveState(state);
99
+ console.log(`relay ${state.relay}\npublic URL in QR codes ${publicRelayURL(state)}${state.publicRelay ? "" : " (from the relay's env file)"}\nhost ${state.hostName} (${state.hostId})\nowner ${state.owner ? `${state.owner.login} (${state.owner.provider})` : "none yet: pair a phone to claim it"}`);
100
+ break;
101
+ }
102
+ case "pair":
103
+ await ensureEnrolled(values.relay, console.log, values.name);
104
+ await pair({ confirm: values.confirm, yes: values.yes, timeoutSeconds: Number(values.timeout), pngPath: values.png });
105
+ break;
106
+ case "start":
107
+ await ensureEnrolled(values.relay, console.log, values.name);
108
+ startService();
109
+ break;
110
+ case "devices": {
111
+ const state = loadState();
112
+ const { owner, devices } = await relayRequest (state.relay, "GET", "/v1/host/devices", { token: state.hostToken });
113
+ console.log(owner ? `Owner: ${owner.login} (${owner.provider})` : "No owner yet.");
114
+ if (!devices.length) console.log("No paired phones.");
115
+ for (const d of devices) console.log(`${d.deviceId} ${d.name} (${d.login}) paired ${new Date(d.createdAt).toLocaleString()} ${d.revokedAt ? "removed" : d.online ? "online" : "offline"}`);
116
+ break;
117
+ }
118
+ case "revoke": {
119
+ const deviceId = positionals[0];
120
+ if (!deviceId) throw new Error("usage: replica-bridge revoke <deviceId>");
121
+ const state = loadState();
122
+ await relayRequest(state.relay, "DELETE", `/v1/host/devices/${encodeURIComponent(deviceId)}`, { token: state.hostToken });
123
+ state.devices = state.devices.filter((d) => d.deviceId !== deviceId);
124
+ saveState(state);
125
+ console.log(`Removed ${deviceId} from this computer.`);
126
+ break;
127
+ }
128
+ default:
129
+ console.log(usage);
130
+ process.exitCode = 1;
131
+ }
132
+ }
133
+
134
+ main().catch((error) => {
135
+ console.error(`replica-bridge: ${(error ).message}`);
136
+ process.exit(1);
137
+ });
@@ -0,0 +1,137 @@
1
+ // replica-bridge pair: shows a QR code and a pairing code, waits for a signed-in phone to claim it, checks the claim
2
+ // and approves it. A QR claim carries an HMAC made with the secret only the QR code holds; a typed-code claim has
3
+ // none, so the person at the computer compares the six-digit safety code. The first claim binds this computer to
4
+ // the phone's account, so it is always confirmed here; later QR claims from the same account go through by themselves.
5
+ import crypto from "node:crypto";
6
+ import readline from "node:readline/promises";
7
+ import QRCode from "qrcode";
8
+ import {
9
+ b64url, buildPairingLink, formatCode, fromB64url, safetyCode, secretHash, verifyPairingProof,
10
+ } from "../protocol.js";
11
+ import { relayRequest } from "../http.js";
12
+ import { loadState, saveState } from "./state.js";
13
+
14
+ import { loadRelayConfig } from "../relay/config.js";
15
+
16
+ /**
17
+ * The address the phone should use: the bridge's own setting, else (when the bridge reaches the relay on this
18
+ * machine) RELAY_PUBLIC_ORIGIN from the relay's env file, so a new tunnel address or domain is set in one place.
19
+ */
20
+ export function publicRelayURL(state ) {
21
+ if (state.publicRelay) return state.publicRelay;
22
+ const host = new URL(state.relay).hostname;
23
+ if (["127.0.0.1", "localhost", "[::1]"].includes(host)) return loadRelayConfig().publicOrigin ?? state.relay;
24
+ return state.relay;
25
+ }
26
+
27
+ /** What the bridge knows about a claim when it asks the person at the computer. */
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+
55
+
56
+ async function askTerminal(claim ) {
57
+ if (!process.stdin.isTTY) {
58
+ console.log("No terminal to confirm on; rejecting. Run pair in a terminal, or pass --yes to approve the first claim.");
59
+ return false;
60
+ }
61
+ const who = claim.provider === "github" ? `GitHub user @${claim.login}` : `${claim.login} (${claim.provider})`;
62
+ const lines = [`${who} wants to use Codex on this computer from "${claim.deviceName}".`];
63
+ if (claim.firstClaim) lines.push("This makes the computer theirs: from now on only their account can pair with it.");
64
+ if (claim.safetyCode) lines.push(`They typed the code instead of scanning it. Safety code: ${claim.safetyCode} — it must match the phone.`);
65
+ console.log(lines.join("\n"));
66
+ const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
67
+ try {
68
+ return /^y(es)?$/i.test((await prompt.question("Allow? [y/N] ")).trim());
69
+ } finally {
70
+ prompt.close();
71
+ }
72
+ }
73
+
74
+ export async function pair(options ) {
75
+ const state = loadState();
76
+ const say = (text ) => { if (!options.quiet) console.log(text); };
77
+ const secret = crypto.randomBytes(32);
78
+ const { code, expiresAt } = await relayRequest (state.relay, "POST", "/v1/pair/start", { token: state.hostToken, body: { secretHash: secretHash(secret) } });
79
+ const link = buildPairingLink({ relay: publicRelayURL(state), hostId: state.hostId, hostPublicKey: fromB64url(state.hostPublicKey), code, secret, hostName: state.hostName });
80
+ options.onLink?.(link, code);
81
+ if (!options.quiet) {
82
+ console.log(await QRCode.toString(link, { type: "terminal", small: true, errorCorrectionLevel: "M" }));
83
+ console.log(`Scan with the app (远程 › 添加连接 › 配对新设备) or the iPhone camera, or type the code: ${formatCode(code)}`);
84
+ console.log(`Link: ${link}`);
85
+ console.log(`Expires at ${new Date(expiresAt).toLocaleTimeString()}. Waiting for the phone…`);
86
+ }
87
+ if (options.pngPath) {
88
+ await QRCode.toFile(options.pngPath, link, { width: 512, errorCorrectionLevel: "M" });
89
+ say(`QR code also saved to ${options.pngPath}`);
90
+ }
91
+
92
+ const deadline = Math.min(expiresAt, Date.now() + options.timeoutSeconds * 1000);
93
+ let claim = null;
94
+ while (Date.now() < deadline) {
95
+ const view = await relayRequest (state.relay, "GET", `/v1/pair/claim?code=${code}`, { token: state.hostToken });
96
+ if (view.status === "claimed") { claim = view; break; }
97
+ if (view.status !== "open") throw new Error(`pairing ${view.status}`);
98
+ await new Promise((resolve) => setTimeout(resolve, 1000));
99
+ }
100
+ if (!claim?.devicePublicKey || !claim.deviceId || !claim.account) throw new Error("pairing timed out");
101
+
102
+ const devicePublicKey = fromB64url(claim.devicePublicKey);
103
+ const info = {
104
+ login: claim.account.login, provider: claim.account.provider, deviceName: claim.deviceName ?? "iPhone",
105
+ firstClaim: claim.firstClaim === true, safetyCode: claim.proof ? null : safetyCode(fromB64url(state.hostPublicKey), devicePublicKey),
106
+ };
107
+ const decide = options.decide ?? askTerminal;
108
+ let approve ;
109
+ if (claim.proof && !verifyPairingProof(secret, code, devicePublicKey, fromB64url(claim.proof))) {
110
+ say("The claim's proof does not match this QR code; rejecting it.");
111
+ approve = false;
112
+ } else if (info.safetyCode || options.confirm || (info.firstClaim && !options.yes)) {
113
+ approve = await decide(info);
114
+ } else {
115
+ approve = true;
116
+ }
117
+ if (!approve) {
118
+ await relayRequest(state.relay, "POST", "/v1/pair/approve", { token: state.hostToken, body: { code, approve: false } });
119
+ throw new Error("pairing rejected");
120
+ }
121
+ // Save the phone's key before approving: the phone may connect the moment the relay says yes.
122
+ const withDevice = (s ) => ({
123
+ ...s,
124
+ owner: s.owner ?? { login: info.login, provider: info.provider },
125
+ devices: [...s.devices.filter((d) => d.deviceId !== claim .deviceId), { deviceId: claim .deviceId , publicKey: b64url(devicePublicKey), name: info.deviceName, login: info.login, pairedAt: new Date().toISOString() }],
126
+ });
127
+ saveState(withDevice(loadState()));
128
+ try {
129
+ await relayRequest(state.relay, "POST", "/v1/pair/approve", { token: state.hostToken, body: { code, approve: true } });
130
+ } catch (error) {
131
+ const latest = loadState();
132
+ saveState({ ...latest, owner: state.owner, devices: latest.devices.filter((d) => d.deviceId !== claim .deviceId) });
133
+ throw error;
134
+ }
135
+ say(`Paired "${info.deviceName}" of ${info.login}.`);
136
+ return { deviceId: claim.deviceId, name: info.deviceName, login: info.login };
137
+ }
@@ -0,0 +1,307 @@
1
+ // replica-bridge start: keeps one WebSocket to the relay and, for each phone stream, one `codex app-server proxy`
2
+ // speaking WebSocket to the long-lived app-server, exactly as the app's SSH path does (Sources/Core/CodexSession.swift).
3
+ // The bridge does not run or configure Codex: when no app-server answers it asks Codex to start its own managed
4
+ // one (`codex app-server daemon start`). A stream outlives the phone's connection for `streamTTL`, buffering what
5
+ // the app-server says, so a phone that drops off the network resumes where it left off.
6
+ import { spawn } from "node:child_process";
7
+
8
+ import crypto from "node:crypto";
9
+ import os from "node:os";
10
+ import { Duplex } from "node:stream";
11
+ import { WebSocket } from "ws";
12
+
13
+ import {
14
+ CloseCode, FrameCipher, MAX_FRAME_BYTES, StreamBuffer, b64url, fromB64url, generateKeyPair, hostSessionKeys,
15
+ } from "../protocol.js";
16
+
17
+ import { relaySocketURL } from "../http.js";
18
+ import { hostKeyPair, loadState } from "./state.js";
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+ export class BridgeService {
54
+ relay = null;
55
+ conns = new Map ();
56
+ streams = new Map ();
57
+ stopped = false;
58
+ retry = 0;
59
+ log ;
60
+
61
+ options ;
62
+
63
+ constructor(options = {}) {
64
+ this.options = options;
65
+ this.log = options.log ?? ((message) => console.log(`${new Date().toISOString()} ${message}`));
66
+ }
67
+
68
+ start() {
69
+ this.connectRelay();
70
+ }
71
+
72
+ stop() {
73
+ this.stopped = true;
74
+ this.relay?.close();
75
+ for (const stream of this.streams.values()) this.endStream(stream, "bridge stopped");
76
+ }
77
+
78
+ get streamCount() { return this.streams.size; }
79
+
80
+ // MARK: - Relay connection
81
+
82
+ connectRelay() {
83
+ const state = loadState();
84
+ const ws = new WebSocket(relaySocketURL(state.relay, "/v1/host"), { headers: { authorization: `Bearer ${state.hostToken}` }, maxPayload: MAX_FRAME_BYTES + 4096, perMessageDeflate: false });
85
+ this.relay = ws;
86
+ ws.on("open", () => { this.retry = 0; this.log(`connected to relay ${state.relay} as ${state.hostId}`); });
87
+ ws.on("message", (data ) => {
88
+ let envelope ;
89
+ try { envelope = JSON.parse(data.toString()); } catch { return; }
90
+ this.onEnvelope(envelope);
91
+ });
92
+ ws.on("unexpected-response", (_req, res) => this.log(`relay refused the host connection: HTTP ${res.statusCode}`));
93
+ ws.on("error", (error) => this.log(`relay connection error: ${error.message}`));
94
+ ws.on("close", (code) => {
95
+ for (const conn of this.conns.values()) this.detach(conn);
96
+ this.conns.clear();
97
+ if (this.stopped) return;
98
+ if (code === CloseCode.replaced) this.log("another bridge with the same identity connected; reconnecting anyway");
99
+ const delay = Math.min(30_000, 1000 * 2 ** this.retry++);
100
+ setTimeout(() => this.connectRelay(), delay);
101
+ });
102
+ }
103
+
104
+ sendEnvelope(envelope ) {
105
+ if (this.relay?.readyState === WebSocket.OPEN) this.relay.send(JSON.stringify(envelope));
106
+ }
107
+
108
+ sendFrame(conn , frame ) {
109
+ this.sendEnvelope({ type: "frame", conn: conn.id, frame });
110
+ }
111
+
112
+ sendInner(conn , message ) {
113
+ if (conn.cipher) this.sendFrame(conn, conn.cipher.sealFrame(message));
114
+ }
115
+
116
+ closeConn(conn , reason ) {
117
+ this.sendEnvelope({ type: "close", conn: conn.id, reason });
118
+ this.detach(conn);
119
+ this.conns.delete(conn.id);
120
+ }
121
+
122
+ onEnvelope(envelope ) {
123
+ if (envelope.type === "open") {
124
+ this.conns.set(envelope.conn, { id: envelope.conn, deviceId: envelope.device, cipher: null, stream: null, queue: Promise.resolve() });
125
+ } else if (envelope.type === "close") {
126
+ const conn = this.conns.get(envelope.conn);
127
+ if (conn) { this.detach(conn); this.conns.delete(conn.id); }
128
+ } else if (envelope.type === "frame") {
129
+ const conn = this.conns.get(envelope.conn);
130
+ if (!conn) return;
131
+ conn.queue = conn.queue.then(() => this.onFrame(conn, envelope.frame)).catch((error) => {
132
+ this.log(`connection ${conn.id} from ${conn.deviceId}: ${(error ).message}`);
133
+ this.sendFrame(conn, { type: "error", code: "protocol", message: "the bridge could not handle a frame" });
134
+ this.closeConn(conn, "protocol error");
135
+ });
136
+ }
137
+ }
138
+
139
+ /** Looks the device up in state.json; a device paired a moment ago may not be saved yet, so look again briefly. */
140
+ async pairedDevice(deviceId ) {
141
+ for (let attempt = 0; attempt < 6; attempt++) {
142
+ const state = loadState();
143
+ const device = state.devices.find((d) => d.deviceId === deviceId);
144
+ if (device) return { state, publicKey: fromB64url(device.publicKey) };
145
+ await new Promise((resolve) => setTimeout(resolve, 500));
146
+ }
147
+ return null;
148
+ }
149
+
150
+ async onFrame(conn , frame ) {
151
+ if (frame.type === "hello") {
152
+ if (conn.cipher) throw new Error("second hello");
153
+ const paired = await this.pairedDevice(conn.deviceId);
154
+ if (!paired) {
155
+ this.sendFrame(conn, { type: "error", code: "unknown_device", message: "this phone is not paired with this computer" });
156
+ return this.closeConn(conn, "unknown device");
157
+ }
158
+ const deviceEphemeral = fromB64url(frame.e);
159
+ if (deviceEphemeral.length !== 32) throw new Error("bad ephemeral key");
160
+ const ephemeral = generateKeyPair();
161
+ const keys = hostSessionKeys(hostKeyPair(paired.state), ephemeral, paired.publicKey, deviceEphemeral);
162
+ conn.cipher = new FrameCipher(keys.hostToDevice, keys.deviceToHost, keys.transcript);
163
+ this.sendFrame(conn, { type: "welcome", v: 1, e: b64url(ephemeral.publicKey) });
164
+ return;
165
+ }
166
+ if (frame.type !== "data" || !conn.cipher) throw new Error(`unexpected ${frame.type} frame`);
167
+ const message = conn.cipher.openFrame(frame);
168
+ if (message.t === "resume") {
169
+ if (conn.stream) throw new Error("second resume");
170
+ await this.resume(conn, message.stream, message.ack);
171
+ } else if (message.t === "msg") {
172
+ const stream = conn.stream;
173
+ if (!stream) throw new Error("message before resume");
174
+ if (!stream.in.accept(message.seq)) return;
175
+ if (stream.appServer.readyState === WebSocket.OPEN) stream.appServer.send(message.line);
176
+ this.scheduleAck(stream);
177
+ } else if (message.t === "ack") {
178
+ conn.stream?.out.ack(message.seq);
179
+ }
180
+ }
181
+
182
+ // MARK: - Streams
183
+
184
+ async resume(conn , streamId , peerAck ) {
185
+ let stream = streamId ? this.streams.get(streamId) : undefined;
186
+ const resumed = !!stream && stream.deviceId === conn.deviceId;
187
+ if (!resumed) {
188
+ stream = await this.openStream(conn.deviceId);
189
+ }
190
+ const s = stream ;
191
+ if (s.conn && s.conn !== conn) this.closeConn(s.conn, "resumed elsewhere");
192
+ if (s.expiry) { clearTimeout(s.expiry); s.expiry = null; }
193
+ s.conn = conn;
194
+ conn.stream = s;
195
+ this.sendInner(conn, { t: "ready", stream: s.id, resumed, ack: s.in.received });
196
+ // A new stream's sequence numbers start over, whatever the phone acknowledged on its old one.
197
+ const after = resumed ? peerAck : 0;
198
+ s.out.ack(after);
199
+ for (const item of s.out.pending(after)) this.sendInner(conn, { t: "msg", seq: item.seq, line: item.line });
200
+ this.log(`${resumed ? "resumed" : "opened"} stream ${s.id} for ${conn.deviceId}`);
201
+ }
202
+
203
+ async openStream(deviceId ) {
204
+ const appServer = await this.connectAppServer();
205
+ const stream = { id: crypto.randomBytes(12).toString("base64url"), deviceId, appServer, out: new StreamBuffer(), in: new StreamBuffer(), conn: null, expiry: null, ackTimer: null };
206
+ this.streams.set(stream.id, stream);
207
+ appServer.on("message", (data ) => {
208
+ let item;
209
+ try { item = stream.out.push(data.toString()); } catch { return this.endStream(stream, "the phone stayed away too long and the buffer filled up"); }
210
+ if (stream.conn) this.sendInner(stream.conn, { t: "msg", seq: item.seq, line: item.line });
211
+ });
212
+ appServer.on("close", () => this.endStream(stream, "codex app-server closed the connection"));
213
+ appServer.on("error", (error) => this.log(`app-server stream ${stream.id}: ${error.message}`));
214
+ return stream;
215
+ }
216
+
217
+ scheduleAck(stream ) {
218
+ if (stream.ackTimer) return;
219
+ stream.ackTimer = setTimeout(() => {
220
+ stream.ackTimer = null;
221
+ if (stream.conn) this.sendInner(stream.conn, { t: "ack", seq: stream.in.received });
222
+ }, 50);
223
+ }
224
+
225
+ detach(conn ) {
226
+ const stream = conn.stream;
227
+ conn.stream = null;
228
+ if (!stream || stream.conn !== conn) return;
229
+ stream.conn = null;
230
+ const ttl = (this.options.streamTTLSeconds ?? 1800) * 1000;
231
+ stream.expiry = setTimeout(() => this.endStream(stream, "the phone did not come back in time"), ttl);
232
+ }
233
+
234
+ endStream(stream , reason ) {
235
+ if (!this.streams.delete(stream.id)) return;
236
+ if (stream.expiry) clearTimeout(stream.expiry);
237
+ if (stream.ackTimer) clearTimeout(stream.ackTimer);
238
+ if (stream.conn) {
239
+ this.sendInner(stream.conn, { t: "closed", reason });
240
+ this.closeConn(stream.conn, reason);
241
+ }
242
+ stream.appServer.close();
243
+ this.log(`closed stream ${stream.id}: ${reason}`);
244
+ }
245
+
246
+ // MARK: - codex app-server
247
+
248
+ /**
249
+ * One `codex app-server proxy` per stream, with WebSocket over its stdin and stdout. The proxy only relays bytes to
250
+ * the app-server's control socket, wherever Codex keeps it; it exits at once when no app-server is running.
251
+ */
252
+ openProxy() {
253
+ return new Promise((resolve, reject) => {
254
+ const child = spawn(this.options.codexPath ?? "codex", ["app-server", "proxy"], { stdio: ["pipe", "pipe", "pipe"] });
255
+ let stderr = "";
256
+ child.stderr .on("data", (chunk) => { stderr = (stderr + chunk).slice(-2000); });
257
+ const pipe = Duplex.from({ readable: child.stdout , writable: child.stdin }) ;
258
+ // The HTTP client that carries the WebSocket handshake tunes its socket; a pipe has nothing to tune.
259
+ for (const name of ["setNoDelay", "setKeepAlive", "setTimeout", "ref", "unref"]) pipe[name] = () => pipe;
260
+ const ws = new WebSocket("ws://localhost/", { createConnection: () => pipe , perMessageDeflate: false, maxPayload: MAX_FRAME_BYTES });
261
+ const fail = (why ) => { ws.removeAllListeners("open"); child.kill(); reject(new Error(why)); };
262
+ child.once("error", (error) => fail(`could not run codex: ${error.message}`));
263
+ child.once("exit", (code) => { if (ws.readyState !== WebSocket.OPEN) fail(`codex app-server proxy exited (${code})${stderr ? `: ${stderr.trim()}` : ""}`); });
264
+ ws.once("error", (error) => { if (ws.readyState !== WebSocket.OPEN) fail(error.message); });
265
+ ws.once("open", () => {
266
+ ws.on("close", () => child.kill());
267
+ resolve(ws);
268
+ });
269
+ });
270
+ }
271
+
272
+ /** Runs a command to completion; true when it exits 0 within 30 s. Output is dropped (an rc file may print). */
273
+ run(command , args ) {
274
+ return new Promise((resolve, reject) => {
275
+ const child = spawn(command, args, { stdio: "ignore" });
276
+ const timer = setTimeout(() => { child.kill(); resolve(false); }, 30_000);
277
+ child.once("error", (error) => { clearTimeout(timer); reject(error); });
278
+ child.once("exit", (code) => { clearTimeout(timer); resolve(code === 0); });
279
+ });
280
+ }
281
+
282
+ async connectAppServer() {
283
+ try {
284
+ return await this.openProxy();
285
+ } catch (error) {
286
+ this.log(`no app-server answered (${(error ).message}); asking Codex to start its daemon`);
287
+ }
288
+ // Codex manages its long-lived app-server itself (start, updates, socket); the bridge only asks for it. However
289
+ // people configure Codex (config.toml, auth.json, cc-switch, keys exported in their shell rc), the daemon should
290
+ // see what their terminal sees, so ask through their login shell; fall back to a direct start.
291
+ const codex = this.options.codexPath ?? "codex";
292
+ // launchd services may have no SHELL; the account's login shell is the same answer.
293
+ const shell = process.env.SHELL || os.userInfo().shell;
294
+ const started = this.options.loginShell !== false && shell
295
+ ? await this.run(shell, ["-lic", 'exec "$0" app-server daemon start', codex]).catch(() => false)
296
+ : false;
297
+ if (!started && !(await this.run(codex, ["app-server", "daemon", "start"]).catch(() => false))) {
298
+ throw new Error("codex app-server daemon start failed");
299
+ }
300
+ let last = null;
301
+ for (let attempt = 0; attempt < 40; attempt++) {
302
+ try { return await this.openProxy(); } catch (error) { last = error ; }
303
+ await new Promise((resolve) => setTimeout(resolve, 250));
304
+ }
305
+ throw new Error(`codex app-server did not come up: ${last?.message}`);
306
+ }
307
+ }
@@ -0,0 +1,44 @@
1
+ // The bridge's local identity and paired devices, in ~/.config/replica-bridge/state.json (mode 600).
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fromB64url, keyPairFromPrivate } from "../protocol.js";
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+ export const bridgeHome = () => process.env.REPLICA_BRIDGE_HOME ?? path.join(os.homedir(), ".config", "replica-bridge");
26
+ const statePath = () => path.join(bridgeHome(), "state.json");
27
+
28
+ export function loadState() {
29
+ try {
30
+ return JSON.parse(fs.readFileSync(statePath(), "utf8")) ;
31
+ } catch (error) {
32
+ if ((error ).code === "ENOENT") throw new Error(`not set up yet: run "replica-bridge init" first (${statePath()})`);
33
+ throw error;
34
+ }
35
+ }
36
+
37
+ export function saveState(state ) {
38
+ fs.mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 });
39
+ const temporary = `${statePath()}.${process.pid}.part`;
40
+ fs.writeFileSync(temporary, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
41
+ fs.renameSync(temporary, statePath());
42
+ }
43
+
44
+ export const hostKeyPair = (state ) => keyPairFromPrivate(fromB64url(state.hostPrivateKey));
package/src/http.js ADDED
@@ -0,0 +1,32 @@
1
+ // Small JSON client for the relay's HTTP endpoints, shared by the bridge and the test phone.
2
+
3
+ export class RelayHTTPError extends Error {
4
+ status ;
5
+ code ;
6
+ constructor(status , code , message ) {
7
+ super(message);
8
+ this.status = status;
9
+ this.code = code;
10
+ }
11
+ }
12
+
13
+ export async function relayRequest (base , method , path , options = {}) {
14
+ const url = new URL(path.replace(/^\//, ""), base.endsWith("/") ? base : `${base}/`);
15
+ const response = await fetch(url, {
16
+ method,
17
+ headers: { ...(options.body !== undefined ? { "content-type": "application/json" } : {}), ...(options.token ? { authorization: `Bearer ${options.token}` } : {}) },
18
+ body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
19
+ });
20
+ const text = await response.text();
21
+ let value = {};
22
+ try { value = JSON.parse(text); } catch { /* not JSON */ }
23
+ if (!response.ok) throw new RelayHTTPError(response.status, String(value.error ?? response.status), String(value.message ?? text.slice(0, 200)));
24
+ return value ;
25
+ }
26
+
27
+ /** http(s)://relay/prefix + /v1/host → ws(s)://relay/prefix/v1/host */
28
+ export function relaySocketURL(base , path ) {
29
+ const url = new URL(path.replace(/^\//, ""), base.endsWith("/") ? base : `${base}/`);
30
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
31
+ return url.toString();
32
+ }
@@ -0,0 +1,262 @@
1
+ // Shared protocol for replica-relay, replica-bridge and the test phone (Relay/PROTOCOL.md).
2
+ // Everything here is plain Node: X25519, HKDF-SHA256, ChaCha20-Poly1305 and HMAC-SHA256 match CryptoKit's
3
+ // Curve25519.KeyAgreement, HKDF<SHA256>, ChaChaPoly and HMAC<SHA256>, so the Swift side can follow the same steps.
4
+ import crypto from "node:crypto";
5
+
6
+ export const PROTOCOL = "chatreplica-relay-v1";
7
+ export const LINK_SCHEME = "chatreplica:";
8
+ /** Pairing codes avoid 0/O, 1/I/L and U so they survive being read aloud or typed. */
9
+ export const CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTVWXYZ";
10
+ export const CODE_LENGTH = 8;
11
+ export const MAX_FRAME_BYTES = 16 * 1024 * 1024;
12
+
13
+ export const b64url = (data ) => Buffer.from(data).toString("base64url");
14
+ export const fromB64url = (text ) => Buffer.from(text, "base64url");
15
+ export const sha256 = (...parts ) => {
16
+ const hash = crypto.createHash("sha256");
17
+ for (const part of parts) hash.update(part);
18
+ return hash.digest();
19
+ };
20
+ export const randomToken = () => b64url(crypto.randomBytes(32));
21
+ export const tokenHash = (token ) => sha256(Buffer.from(token, "utf8")).toString("hex");
22
+
23
+ export function randomCode() {
24
+ let code = "";
25
+ while (code.length < CODE_LENGTH) {
26
+ const byte = crypto.randomBytes(1)[0];
27
+ if (byte < 240) code += CODE_ALPHABET[byte % CODE_ALPHABET.length]; // 240 = 8 * 30, no modulo bias
28
+ }
29
+ return code;
30
+ }
31
+
32
+ /** Uppercases and drops spaces and dashes, so "abcd-2345" and "ABCD 2345" find the same pairing. */
33
+ export const normalizeCode = (code ) => code.toUpperCase().replace(/[\s-]/g, "");
34
+ export const formatCode = (code ) => `${code.slice(0, 4)}-${code.slice(4)}`;
35
+
36
+ // MARK: - X25519 keys as raw 32-byte values (what CryptoKit's rawRepresentation holds)
37
+
38
+ const PKCS8_X25519_PREFIX = Buffer.from("302e020100300506032b656e04220420", "hex");
39
+ const SPKI_X25519_PREFIX = Buffer.from("302a300506032b656e032100", "hex");
40
+
41
+
42
+
43
+ export function generateKeyPair() {
44
+ return keyPairFromPrivate(crypto.randomBytes(32));
45
+ }
46
+
47
+ export function keyPairFromPrivate(privateKey ) {
48
+ const key = crypto.createPrivateKey({ key: Buffer.concat([PKCS8_X25519_PREFIX, privateKey]), format: "der", type: "pkcs8" });
49
+ const spki = crypto.createPublicKey(key).export({ format: "der", type: "spki" });
50
+ return { privateKey, publicKey: Buffer.from(spki.subarray(SPKI_X25519_PREFIX.length)) };
51
+ }
52
+
53
+ export function dh(privateKey , publicKey ) {
54
+ if (publicKey.length !== 32) throw new Error("X25519 public keys are 32 bytes");
55
+ return crypto.diffieHellman({
56
+ privateKey: crypto.createPrivateKey({ key: Buffer.concat([PKCS8_X25519_PREFIX, privateKey]), format: "der", type: "pkcs8" }),
57
+ publicKey: crypto.createPublicKey({ key: Buffer.concat([SPKI_X25519_PREFIX, publicKey]), format: "der", type: "spki" }),
58
+ });
59
+ }
60
+
61
+ // MARK: - Pairing
62
+
63
+
64
+
65
+
66
+
67
+
68
+
69
+
70
+
71
+
72
+ /** chatreplica://pair?v=1&r=<relay URL>&h=<hostId>&k=<host key>&c=<code>&s=<secret>&n=<host name> */
73
+ export function buildPairingLink(link ) {
74
+ const params = new URLSearchParams({ v: "1", r: link.relay, h: link.hostId, k: b64url(link.hostPublicKey), c: link.code, s: b64url(link.secret) });
75
+ if (link.hostName) params.set("n", link.hostName);
76
+ return `chatreplica://pair?${params}`;
77
+ }
78
+
79
+ export function parsePairingLink(text ) {
80
+ const url = new URL(text.trim());
81
+ if (url.protocol !== LINK_SCHEME || url.hostname !== "pair") throw new Error("not a chatreplica://pair link");
82
+ const get = (name ) => {
83
+ const value = url.searchParams.get(name);
84
+ if (!value) throw new Error(`pairing link is missing ${name}`);
85
+ return value;
86
+ };
87
+ if (get("v") !== "1") throw new Error("unsupported pairing link version");
88
+ const relay = new URL(get("r"));
89
+ if (relay.protocol !== "https:" && relay.protocol !== "http:") throw new Error("relay must be an http(s) URL");
90
+ const hostPublicKey = fromB64url(get("k"));
91
+ const secret = fromB64url(get("s"));
92
+ if (hostPublicKey.length !== 32 || secret.length !== 32) throw new Error("pairing link key or secret has the wrong length");
93
+ return { relay: get("r"), hostId: get("h"), hostPublicKey, code: normalizeCode(get("c")), secret, hostName: url.searchParams.get("n") ?? undefined };
94
+ }
95
+
96
+ /** What the relay keeps instead of the secret, so it cannot compute the proof itself. */
97
+ export const secretHash = (secret ) => b64url(sha256(secret));
98
+
99
+ /** HMAC-SHA256(secret, PROTOCOL "|pair|" code devicePublicKey): proves the phone saw the QR code. */
100
+ export function pairingProof(secret , code , devicePublicKey ) {
101
+ return crypto.createHmac("sha256", secret).update(`${PROTOCOL}|pair|${normalizeCode(code)}`).update(devicePublicKey).digest();
102
+ }
103
+
104
+ export function verifyPairingProof(secret , code , devicePublicKey , proof ) {
105
+ const expected = pairingProof(secret, code, devicePublicKey);
106
+ return proof.length === expected.length && crypto.timingSafeEqual(proof, expected);
107
+ }
108
+
109
+ /** Six digits both screens show when pairing by typed code, where the phone got the host key from the relay. */
110
+ export function safetyCode(hostPublicKey , devicePublicKey ) {
111
+ const digest = sha256(Buffer.from(`${PROTOCOL}|safety`), hostPublicKey, devicePublicKey);
112
+ return String(digest.readUInt32BE(0) % 1_000_000).padStart(6, "0");
113
+ }
114
+
115
+ // MARK: - Session handshake and frames
116
+
117
+
118
+
119
+
120
+
121
+
122
+
123
+
124
+ /**
125
+ * Both static keys and both ephemeral keys go into the transcript; the shared secret mixes ee, es and se, so only the
126
+ * paired phone (device static key) talking to the real host (host static key) derives the same keys.
127
+ */
128
+ export function deriveSessionKeys(args
129
+
130
+
131
+ ) {
132
+ const transcript = sha256(Buffer.from(PROTOCOL), args.hostStatic, args.deviceStatic, args.deviceEphemeral, args.hostEphemeral);
133
+ const okm = Buffer.from(crypto.hkdfSync("sha256", Buffer.concat([args.ee, args.es, args.se]), transcript, Buffer.from(`${PROTOCOL}|keys`), 64));
134
+ return { transcript, deviceToHost: okm.subarray(0, 32), hostToDevice: okm.subarray(32, 64) };
135
+ }
136
+
137
+ /** The phone's side: ee = DH(De, He), es = DH(De, Hs), se = DH(Ds, He). */
138
+ export function deviceSessionKeys(device , deviceEphemeral , hostStatic , hostEphemeral ) {
139
+ return deriveSessionKeys({
140
+ hostStatic, deviceStatic: device.publicKey, deviceEphemeral: deviceEphemeral.publicKey, hostEphemeral,
141
+ ee: dh(deviceEphemeral.privateKey, hostEphemeral), es: dh(deviceEphemeral.privateKey, hostStatic), se: dh(device.privateKey, hostEphemeral),
142
+ });
143
+ }
144
+
145
+ /** The host's side of the same three DH values. */
146
+ export function hostSessionKeys(host , hostEphemeral , deviceStatic , deviceEphemeral ) {
147
+ return deriveSessionKeys({
148
+ hostStatic: host.publicKey, deviceStatic, deviceEphemeral, hostEphemeral: hostEphemeral.publicKey,
149
+ ee: dh(hostEphemeral.privateKey, deviceEphemeral), es: dh(host.privateKey, deviceEphemeral), se: dh(hostEphemeral.privateKey, deviceStatic),
150
+ });
151
+ }
152
+
153
+ /** 96-bit nonce: four zero bytes, then the frame counter as a big-endian UInt64. */
154
+ export function frameNonce(counter ) {
155
+ const nonce = Buffer.alloc(12);
156
+ nonce.writeBigUInt64BE(BigInt(counter), 4);
157
+ return nonce;
158
+ }
159
+
160
+ export function seal(key , counter , aad , plaintext ) {
161
+ const cipher = crypto.createCipheriv("chacha20-poly1305", key, frameNonce(counter), { authTagLength: 16 });
162
+ cipher.setAAD(aad, { plaintextLength: plaintext.length });
163
+ return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
164
+ }
165
+
166
+ export function open(key , counter , aad , sealed ) {
167
+ if (sealed.length < 16) throw new Error("frame too short");
168
+ const decipher = crypto.createDecipheriv("chacha20-poly1305", key, frameNonce(counter), { authTagLength: 16 });
169
+ decipher.setAAD(aad, { plaintextLength: sealed.length - 16 });
170
+ decipher.setAuthTag(sealed.subarray(sealed.length - 16));
171
+ return Buffer.concat([decipher.update(sealed.subarray(0, sealed.length - 16)), decipher.final()]);
172
+ }
173
+
174
+ /** One direction of an established session: counts frames and refuses gaps or replays. */
175
+ export class FrameCipher {
176
+ sendCounter = 0;
177
+ receiveCounter = 0;
178
+ sendKey ;
179
+ receiveKey ;
180
+ aad ;
181
+ constructor(sendKey , receiveKey , aad ) { this.sendKey = sendKey; this.receiveKey = receiveKey; this.aad = aad; }
182
+
183
+ sealFrame(message ) {
184
+ const n = this.sendCounter++;
185
+ return { type: "data", n, c: b64url(seal(this.sendKey, n, this.aad, Buffer.from(JSON.stringify(message), "utf8"))) };
186
+ }
187
+
188
+ openFrame(frame ) {
189
+ if (frame.n !== this.receiveCounter) throw new Error(`frame ${frame.n} out of order (expected ${this.receiveCounter})`);
190
+ const plaintext = open(this.receiveKey, frame.n, this.aad, fromB64url(frame.c));
191
+ this.receiveCounter++;
192
+ return JSON.parse(plaintext.toString("utf8")) ;
193
+ }
194
+ }
195
+
196
+ /** What travels between the phone and the host through the relay; the relay only sees these. */
197
+
198
+
199
+
200
+
201
+
202
+
203
+ /** What is inside a data frame. */
204
+
205
+
206
+
207
+
208
+
209
+
210
+
211
+ /** Relay ↔ host multiplexing: one host WebSocket carries every phone connection. */
212
+
213
+
214
+
215
+
216
+
217
+ /** WebSocket close codes the relay and the bridge use. */
218
+ export const CloseCode = {
219
+ hostOffline: 4404,
220
+ revoked: 4403,
221
+ replaced: 4409,
222
+ protocolError: 4400,
223
+ streamGone: 4410,
224
+ } ;
225
+
226
+ /**
227
+ * Keeps one direction of a stream's messages until the peer acknowledges them, so a reconnect can resend exactly
228
+ * what was lost. Sequence numbers start at 1; `received` is the highest contiguous seq taken from the peer.
229
+ */
230
+ export class StreamBuffer {
231
+ nextSeq = 1;
232
+ received = 0;
233
+ unacked = [];
234
+ bytes = 0;
235
+
236
+ maxBytes ;
237
+ constructor(maxBytes = 32 * 1024 * 1024) { this.maxBytes = maxBytes; }
238
+
239
+ push(line ) {
240
+ const item = { seq: this.nextSeq++, line, size: Buffer.byteLength(line) };
241
+ this.unacked.push(item);
242
+ this.bytes += item.size;
243
+ if (this.bytes > this.maxBytes) throw new Error("unacknowledged buffer full");
244
+ return item;
245
+ }
246
+
247
+ ack(seq ) {
248
+ while (this.unacked.length && this.unacked[0].seq <= seq) this.bytes -= this.unacked.shift() .size;
249
+ }
250
+
251
+ pending(after ) {
252
+ return this.unacked.filter((item) => item.seq > after);
253
+ }
254
+
255
+ /** True when the line is new; duplicates from a resend are dropped. Gaps cannot happen on one ordered socket. */
256
+ accept(seq ) {
257
+ if (seq <= this.received) return false;
258
+ if (seq !== this.received + 1) throw new Error(`message ${seq} skips ahead of ${this.received}`);
259
+ this.received = seq;
260
+ return true;
261
+ }
262
+ }
@@ -0,0 +1,76 @@
1
+ // Reads ~/.config/replica-relay/relay.env (Relay/deploy/relay.env.example). Real environment variables win over
2
+ // the file, so a systemd Environment= line or a one-off `RELAY_PORT=… node …` still works.
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ /** RELAY_ENV points somewhere else (the quick-tunnel script and the tests use it). */
8
+ export const defaultEnvPath = () => process.env.RELAY_ENV ?? path.join(os.homedir(), ".config", "replica-relay", "relay.env");
9
+
10
+ export function readEnvFile(file ) {
11
+ let text ;
12
+ try {
13
+ text = fs.readFileSync(file, "utf8");
14
+ } catch (error) {
15
+ if ((error ).code === "ENOENT") return {};
16
+ throw error;
17
+ }
18
+ const values = {};
19
+ for (const raw of text.split("\n")) {
20
+ const line = raw.trim();
21
+ if (!line || line.startsWith("#")) continue;
22
+ const eq = line.indexOf("=");
23
+ if (eq < 1) continue;
24
+ let value = line.slice(eq + 1).trim();
25
+ if (/^(".*"|'.*')$/.test(value)) value = value.slice(1, -1);
26
+ values[line.slice(0, eq).trim()] = value;
27
+ }
28
+ return values;
29
+ }
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+ /** "github:583231, 12345, *" → ["583231", "12345", "*"]; anything that is not a numeric id or * is refused. */
46
+ export function parseAllowedIds(text ) {
47
+ return text.split(",").map((item) => item.trim()).filter(Boolean).map((item) => {
48
+ const id = item.replace(/^github:/, "");
49
+ if (id !== "*" && !/^[0-9]{1,20}$/.test(id)) throw new Error(`GITHUB_ALLOWED_USERS takes numeric GitHub ids (github:123) or *, not "${item}"`);
50
+ return id;
51
+ });
52
+ }
53
+
54
+ const expandHome = (value ) => (value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value);
55
+
56
+ export function loadRelayConfig(envPath = defaultEnvPath()) {
57
+ const file = readEnvFile(envPath);
58
+ const get = (name ) => {
59
+ const value = process.env[name] ?? file[name];
60
+ return value === undefined || value === "" ? null : value;
61
+ };
62
+ const origin = get("RELAY_PUBLIC_ORIGIN");
63
+ if (origin && !/^https?:\/\/[^/]+\/?$/.test(origin)) throw new Error(`RELAY_PUBLIC_ORIGIN must look like https://host[:port], got ${origin}`);
64
+ return {
65
+ listen: get("RELAY_LISTEN") ?? "127.0.0.1",
66
+ port: Number(get("RELAY_PORT") ?? 8790),
67
+ publicOrigin: origin?.replace(/\/$/, "") ?? null,
68
+ trustProxy: get("RELAY_TRUST_PROXY") === "1",
69
+ dataDir: expandHome(get("RELAY_DATA_DIR") ?? "~/.local/share/replica-relay"),
70
+ devLogin: get("RELAY_DEV_LOGIN") === "1",
71
+ githubClientId: get("GITHUB_CLIENT_ID"),
72
+ githubClientSecret: get("GITHUB_CLIENT_SECRET"),
73
+ githubCallbackUrl: get("GITHUB_CALLBACK_URL"),
74
+ githubAllowedIds: parseAllowedIds(get("GITHUB_ALLOWED_USERS") ?? ""),
75
+ };
76
+ }