replica-bridge 0.0.0-stage → 0.2.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 +13 -2
- package/package.json +15 -4
- package/src/bridge/command.js +28 -0
- package/src/bridge/enroll.js +39 -0
- package/src/bridge/install.js +253 -0
- package/src/bridge/main.js +237 -0
- package/src/bridge/pair.js +137 -0
- package/src/bridge/service.js +319 -0
- package/src/bridge/state.js +44 -0
- package/src/http.js +32 -0
- package/src/protocol.js +262 -0
- package/src/relay/config.js +76 -0
|
@@ -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,319 @@
|
|
|
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
|
+
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import { Duplex } from "node:stream";
|
|
10
|
+
import { WebSocket } from "ws";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
CloseCode, FrameCipher, MAX_FRAME_BYTES, StreamBuffer, b64url, fromB64url, generateKeyPair, hostSessionKeys,
|
|
14
|
+
} from "../protocol.js";
|
|
15
|
+
|
|
16
|
+
import { relaySocketURL } from "../http.js";
|
|
17
|
+
import { killCommand, spawnCommand } from "./command.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
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
export class BridgeService {
|
|
59
|
+
relay = null;
|
|
60
|
+
conns = new Map ();
|
|
61
|
+
streams = new Map ();
|
|
62
|
+
stopped = false;
|
|
63
|
+
retry = 0;
|
|
64
|
+
log ;
|
|
65
|
+
|
|
66
|
+
options ;
|
|
67
|
+
|
|
68
|
+
constructor(options = {}) {
|
|
69
|
+
this.options = options;
|
|
70
|
+
this.log = options.log ?? ((message) => console.log(`${new Date().toISOString()} ${message}`));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
start() {
|
|
74
|
+
this.connectRelay();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
stop() {
|
|
78
|
+
this.stopped = true;
|
|
79
|
+
this.relay?.close();
|
|
80
|
+
for (const stream of this.streams.values()) this.endStream(stream, "bridge stopped");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get streamCount() { return this.streams.size; }
|
|
84
|
+
|
|
85
|
+
// MARK: - Relay connection
|
|
86
|
+
|
|
87
|
+
connectRelay() {
|
|
88
|
+
const state = loadState();
|
|
89
|
+
const ws = new WebSocket(relaySocketURL(state.relay, "/v1/host"), { headers: { authorization: `Bearer ${state.hostToken}` }, maxPayload: MAX_FRAME_BYTES + 4096, perMessageDeflate: false });
|
|
90
|
+
this.relay = ws;
|
|
91
|
+
ws.on("open", () => { this.retry = 0; this.log(`connected to relay ${state.relay} as ${state.hostId}`); });
|
|
92
|
+
ws.on("message", (data ) => {
|
|
93
|
+
let envelope ;
|
|
94
|
+
try { envelope = JSON.parse(data.toString()); } catch { return; }
|
|
95
|
+
this.onEnvelope(envelope);
|
|
96
|
+
});
|
|
97
|
+
ws.on("unexpected-response", (_req, res) => this.log(`relay refused the host connection: HTTP ${res.statusCode}`));
|
|
98
|
+
ws.on("error", (error) => this.log(`relay connection error: ${error.message}`));
|
|
99
|
+
ws.on("close", (code) => {
|
|
100
|
+
if (ws !== this.relay) return; // a socket this bridge already replaced itself
|
|
101
|
+
for (const conn of this.conns.values()) this.detach(conn);
|
|
102
|
+
this.conns.clear();
|
|
103
|
+
if (this.stopped) return;
|
|
104
|
+
if (code === CloseCode.replaced) {
|
|
105
|
+
this.log("another bridge for this computer connected to the relay, which keeps only the newest; stopping this one");
|
|
106
|
+
this.stop();
|
|
107
|
+
this.options.onReplaced?.();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const delay = Math.min(30_000, 1000 * 2 ** this.retry++);
|
|
111
|
+
setTimeout(() => this.connectRelay(), delay);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
sendEnvelope(envelope ) {
|
|
116
|
+
if (this.relay?.readyState === WebSocket.OPEN) this.relay.send(JSON.stringify(envelope));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
sendFrame(conn , frame ) {
|
|
120
|
+
this.sendEnvelope({ type: "frame", conn: conn.id, frame });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
sendInner(conn , message ) {
|
|
124
|
+
if (conn.cipher) this.sendFrame(conn, conn.cipher.sealFrame(message));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
closeConn(conn , reason ) {
|
|
128
|
+
this.sendEnvelope({ type: "close", conn: conn.id, reason });
|
|
129
|
+
this.detach(conn);
|
|
130
|
+
this.conns.delete(conn.id);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
onEnvelope(envelope ) {
|
|
134
|
+
if (envelope.type === "open") {
|
|
135
|
+
this.conns.set(envelope.conn, { id: envelope.conn, deviceId: envelope.device, cipher: null, stream: null, queue: Promise.resolve() });
|
|
136
|
+
} else if (envelope.type === "close") {
|
|
137
|
+
const conn = this.conns.get(envelope.conn);
|
|
138
|
+
if (conn) { this.detach(conn); this.conns.delete(conn.id); }
|
|
139
|
+
} else if (envelope.type === "frame") {
|
|
140
|
+
const conn = this.conns.get(envelope.conn);
|
|
141
|
+
if (!conn) return;
|
|
142
|
+
conn.queue = conn.queue.then(() => this.onFrame(conn, envelope.frame)).catch((error) => {
|
|
143
|
+
this.log(`connection ${conn.id} from ${conn.deviceId}: ${(error ).message}`);
|
|
144
|
+
this.sendFrame(conn, { type: "error", code: "protocol", message: "the bridge could not handle a frame" });
|
|
145
|
+
this.closeConn(conn, "protocol error");
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Looks the device up in state.json; a device paired a moment ago may not be saved yet, so look again briefly. */
|
|
151
|
+
async pairedDevice(deviceId ) {
|
|
152
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
153
|
+
const state = loadState();
|
|
154
|
+
const device = state.devices.find((d) => d.deviceId === deviceId);
|
|
155
|
+
if (device) return { state, publicKey: fromB64url(device.publicKey) };
|
|
156
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async onFrame(conn , frame ) {
|
|
162
|
+
if (frame.type === "hello") {
|
|
163
|
+
if (conn.cipher) throw new Error("second hello");
|
|
164
|
+
const paired = await this.pairedDevice(conn.deviceId);
|
|
165
|
+
if (!paired) {
|
|
166
|
+
this.sendFrame(conn, { type: "error", code: "unknown_device", message: "this phone is not paired with this computer" });
|
|
167
|
+
return this.closeConn(conn, "unknown device");
|
|
168
|
+
}
|
|
169
|
+
const deviceEphemeral = fromB64url(frame.e);
|
|
170
|
+
if (deviceEphemeral.length !== 32) throw new Error("bad ephemeral key");
|
|
171
|
+
const ephemeral = generateKeyPair();
|
|
172
|
+
const keys = hostSessionKeys(hostKeyPair(paired.state), ephemeral, paired.publicKey, deviceEphemeral);
|
|
173
|
+
conn.cipher = new FrameCipher(keys.hostToDevice, keys.deviceToHost, keys.transcript);
|
|
174
|
+
this.sendFrame(conn, { type: "welcome", v: 1, e: b64url(ephemeral.publicKey) });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (frame.type !== "data" || !conn.cipher) throw new Error(`unexpected ${frame.type} frame`);
|
|
178
|
+
const message = conn.cipher.openFrame(frame);
|
|
179
|
+
if (message.t === "resume") {
|
|
180
|
+
if (conn.stream) throw new Error("second resume");
|
|
181
|
+
await this.resume(conn, message.stream, message.ack);
|
|
182
|
+
} else if (message.t === "msg") {
|
|
183
|
+
const stream = conn.stream;
|
|
184
|
+
if (!stream) throw new Error("message before resume");
|
|
185
|
+
if (!stream.in.accept(message.seq)) return;
|
|
186
|
+
if (stream.appServer.readyState === WebSocket.OPEN) stream.appServer.send(message.line);
|
|
187
|
+
this.scheduleAck(stream);
|
|
188
|
+
} else if (message.t === "ack") {
|
|
189
|
+
conn.stream?.out.ack(message.seq);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// MARK: - Streams
|
|
194
|
+
|
|
195
|
+
async resume(conn , streamId , peerAck ) {
|
|
196
|
+
let stream = streamId ? this.streams.get(streamId) : undefined;
|
|
197
|
+
const resumed = !!stream && stream.deviceId === conn.deviceId;
|
|
198
|
+
if (!resumed) {
|
|
199
|
+
stream = await this.openStream(conn.deviceId);
|
|
200
|
+
}
|
|
201
|
+
const s = stream ;
|
|
202
|
+
if (s.conn && s.conn !== conn) this.closeConn(s.conn, "resumed elsewhere");
|
|
203
|
+
if (s.expiry) { clearTimeout(s.expiry); s.expiry = null; }
|
|
204
|
+
s.conn = conn;
|
|
205
|
+
conn.stream = s;
|
|
206
|
+
this.sendInner(conn, { t: "ready", stream: s.id, resumed, ack: s.in.received });
|
|
207
|
+
// A new stream's sequence numbers start over, whatever the phone acknowledged on its old one.
|
|
208
|
+
const after = resumed ? peerAck : 0;
|
|
209
|
+
s.out.ack(after);
|
|
210
|
+
for (const item of s.out.pending(after)) this.sendInner(conn, { t: "msg", seq: item.seq, line: item.line });
|
|
211
|
+
this.log(`${resumed ? "resumed" : "opened"} stream ${s.id} for ${conn.deviceId}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async openStream(deviceId ) {
|
|
215
|
+
const appServer = await this.connectAppServer();
|
|
216
|
+
const stream = { id: crypto.randomBytes(12).toString("base64url"), deviceId, appServer, out: new StreamBuffer(), in: new StreamBuffer(), conn: null, expiry: null, ackTimer: null };
|
|
217
|
+
this.streams.set(stream.id, stream);
|
|
218
|
+
appServer.on("message", (data ) => {
|
|
219
|
+
let item;
|
|
220
|
+
try { item = stream.out.push(data.toString()); } catch { return this.endStream(stream, "the phone stayed away too long and the buffer filled up"); }
|
|
221
|
+
if (stream.conn) this.sendInner(stream.conn, { t: "msg", seq: item.seq, line: item.line });
|
|
222
|
+
});
|
|
223
|
+
appServer.on("close", () => this.endStream(stream, "codex app-server closed the connection"));
|
|
224
|
+
appServer.on("error", (error) => this.log(`app-server stream ${stream.id}: ${error.message}`));
|
|
225
|
+
return stream;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
scheduleAck(stream ) {
|
|
229
|
+
if (stream.ackTimer) return;
|
|
230
|
+
stream.ackTimer = setTimeout(() => {
|
|
231
|
+
stream.ackTimer = null;
|
|
232
|
+
if (stream.conn) this.sendInner(stream.conn, { t: "ack", seq: stream.in.received });
|
|
233
|
+
}, 50);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
detach(conn ) {
|
|
237
|
+
const stream = conn.stream;
|
|
238
|
+
conn.stream = null;
|
|
239
|
+
if (!stream || stream.conn !== conn) return;
|
|
240
|
+
stream.conn = null;
|
|
241
|
+
const ttl = (this.options.streamTTLSeconds ?? 1800) * 1000;
|
|
242
|
+
stream.expiry = setTimeout(() => this.endStream(stream, "the phone did not come back in time"), ttl);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
endStream(stream , reason ) {
|
|
246
|
+
if (!this.streams.delete(stream.id)) return;
|
|
247
|
+
if (stream.expiry) clearTimeout(stream.expiry);
|
|
248
|
+
if (stream.ackTimer) clearTimeout(stream.ackTimer);
|
|
249
|
+
if (stream.conn) {
|
|
250
|
+
this.sendInner(stream.conn, { t: "closed", reason });
|
|
251
|
+
this.closeConn(stream.conn, reason);
|
|
252
|
+
}
|
|
253
|
+
stream.appServer.close();
|
|
254
|
+
this.log(`closed stream ${stream.id}: ${reason}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// MARK: - codex app-server
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* One `codex app-server proxy` per stream, with WebSocket over its stdin and stdout. The proxy only relays bytes to
|
|
261
|
+
* the app-server's control socket, wherever Codex keeps it; it exits at once when no app-server is running.
|
|
262
|
+
*/
|
|
263
|
+
openProxy() {
|
|
264
|
+
return new Promise((resolve, reject) => {
|
|
265
|
+
const child = spawnCommand(this.options.codexPath ?? "codex", ["app-server", "proxy"], { stdio: ["pipe", "pipe", "pipe"] });
|
|
266
|
+
let stderr = "";
|
|
267
|
+
child.stderr .on("data", (chunk) => { stderr = (stderr + chunk).slice(-2000); });
|
|
268
|
+
const pipe = Duplex.from({ readable: child.stdout , writable: child.stdin }) ;
|
|
269
|
+
// The HTTP client that carries the WebSocket handshake tunes its socket; a pipe has nothing to tune.
|
|
270
|
+
for (const name of ["setNoDelay", "setKeepAlive", "setTimeout", "ref", "unref"]) pipe[name] = () => pipe;
|
|
271
|
+
const ws = new WebSocket("ws://localhost/", { createConnection: () => pipe , perMessageDeflate: false, maxPayload: MAX_FRAME_BYTES });
|
|
272
|
+
const fail = (why ) => { ws.removeAllListeners("open"); killCommand(child); reject(new Error(why)); };
|
|
273
|
+
child.once("error", (error) => fail(`could not run codex: ${error.message}`));
|
|
274
|
+
child.once("exit", (code) => { if (ws.readyState !== WebSocket.OPEN) fail(`codex app-server proxy exited (${code})${stderr ? `: ${stderr.trim()}` : ""}`); });
|
|
275
|
+
ws.once("error", (error) => { if (ws.readyState !== WebSocket.OPEN) fail(error.message); });
|
|
276
|
+
ws.once("open", () => {
|
|
277
|
+
ws.on("close", () => killCommand(child));
|
|
278
|
+
resolve(ws);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Runs a command to completion; true when it exits 0 within 30 s. Output is dropped (an rc file may print). */
|
|
284
|
+
run(command , args ) {
|
|
285
|
+
return new Promise((resolve, reject) => {
|
|
286
|
+
const child = spawnCommand(command, args, { stdio: "ignore" });
|
|
287
|
+
const timer = setTimeout(() => { killCommand(child); resolve(false); }, 30_000);
|
|
288
|
+
child.once("error", (error) => { clearTimeout(timer); reject(error); });
|
|
289
|
+
child.once("exit", (code) => { clearTimeout(timer); resolve(code === 0); });
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async connectAppServer() {
|
|
294
|
+
try {
|
|
295
|
+
return await this.openProxy();
|
|
296
|
+
} catch (error) {
|
|
297
|
+
this.log(`no app-server answered (${(error ).message}); asking Codex to start its daemon`);
|
|
298
|
+
}
|
|
299
|
+
// Codex manages its long-lived app-server itself (start, updates, socket); the bridge only asks for it. However
|
|
300
|
+
// people configure Codex (config.toml, auth.json, cc-switch, keys exported in their shell rc), the daemon should
|
|
301
|
+
// see what their terminal sees, so ask through their login shell; fall back to a direct start.
|
|
302
|
+
const codex = this.options.codexPath ?? "codex";
|
|
303
|
+
// launchd services may have no SHELL; the account's login shell is the same answer. Windows has no login shell
|
|
304
|
+
// (a SHELL from Git Bash is a POSIX path Node cannot run), so Codex is asked directly there.
|
|
305
|
+
const shell = process.platform === "win32" ? null : process.env.SHELL || os.userInfo().shell;
|
|
306
|
+
const started = this.options.loginShell !== false && shell
|
|
307
|
+
? await this.run(shell, ["-lic", 'exec "$0" app-server daemon start', codex]).catch(() => false)
|
|
308
|
+
: false;
|
|
309
|
+
if (!started && !(await this.run(codex, ["app-server", "daemon", "start"]).catch(() => false))) {
|
|
310
|
+
throw new Error("codex app-server daemon start failed");
|
|
311
|
+
}
|
|
312
|
+
let last = null;
|
|
313
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
314
|
+
try { return await this.openProxy(); } catch (error) { last = error ; }
|
|
315
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
316
|
+
}
|
|
317
|
+
throw new Error(`codex app-server did not come up: ${last?.message}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -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
|
+
}
|