@btatum5/codex-bridge 0.1.0 → 1.3.2

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/src/bridge.js ADDED
@@ -0,0 +1,620 @@
1
+ // FILE: bridge.js
2
+ // Purpose: Runs Codex locally, bridges relay traffic, and coordinates desktop refreshes for Codex.app.
3
+ // Layer: CLI service
4
+ // Exports: startBridge
5
+ // Depends on: ws, crypto, os, ./qr, ./codex-desktop-refresher, ./codex-transport, ./rollout-watch
6
+
7
+ const WebSocket = require("ws");
8
+ const { randomBytes } = require("crypto");
9
+ const os = require("os");
10
+ const {
11
+ CodexDesktopRefresher,
12
+ readBridgeConfig,
13
+ } = require("./codex-desktop-refresher");
14
+ const { createCodexTransport } = require("./codex-transport");
15
+ const { createThreadRolloutActivityWatcher } = require("./rollout-watch");
16
+ const { printQR } = require("./qr");
17
+ const { rememberActiveThread } = require("./session-state");
18
+ const { handleDesktopRequest } = require("./desktop-handler");
19
+ const { handleGitRequest } = require("./git-handler");
20
+ const { handleThreadContextRequest } = require("./thread-context-handler");
21
+ const { handleWorkspaceRequest } = require("./workspace-handler");
22
+ const { createNotificationsHandler } = require("./notifications-handler");
23
+ const { createPushNotificationServiceClient } = require("./push-notification-service-client");
24
+ const { createPushNotificationTracker } = require("./push-notification-tracker");
25
+ const {
26
+ loadOrCreateBridgeDeviceState,
27
+ resolveBridgeRelaySession,
28
+ } = require("./secure-device-state");
29
+ const { createBridgeSecureTransport } = require("./secure-transport");
30
+ const { createRolloutLiveMirrorController } = require("./rollout-live-mirror");
31
+
32
+ function startBridge({
33
+ config: explicitConfig = null,
34
+ printPairingQr = true,
35
+ onPairingPayload = null,
36
+ onBridgeStatus = null,
37
+ } = {}) {
38
+ const config = explicitConfig || readBridgeConfig();
39
+ const relayBaseUrl = config.relayUrl.replace(/\/+$/, "");
40
+ if (!relayBaseUrl) {
41
+ console.error("[codex-bridge] No relay URL configured.");
42
+ console.error("[codex-bridge] In a source checkout, run ./run-local-codex.sh or set REMODEX_RELAY.");
43
+ process.exit(1);
44
+ }
45
+
46
+ let deviceState;
47
+ try {
48
+ deviceState = loadOrCreateBridgeDeviceState();
49
+ } catch (error) {
50
+ console.error(`[codex-bridge] ${(error && error.message) || "Failed to load the saved bridge pairing state."}`);
51
+ process.exit(1);
52
+ }
53
+ const relaySession = resolveBridgeRelaySession(deviceState);
54
+ deviceState = relaySession.deviceState;
55
+ const sessionId = relaySession.sessionId;
56
+ const relaySessionUrl = `${relayBaseUrl}/${sessionId}`;
57
+ const notificationSecret = randomBytes(24).toString("hex");
58
+ const desktopRefresher = new CodexDesktopRefresher({
59
+ enabled: config.refreshEnabled,
60
+ debounceMs: config.refreshDebounceMs,
61
+ refreshCommand: config.refreshCommand,
62
+ bundleId: config.codexBundleId,
63
+ appPath: config.codexAppPath,
64
+ });
65
+ const pushServiceClient = createPushNotificationServiceClient({
66
+ baseUrl: config.pushServiceUrl,
67
+ sessionId,
68
+ notificationSecret,
69
+ });
70
+ const notificationsHandler = createNotificationsHandler({
71
+ pushServiceClient,
72
+ });
73
+ const pushNotificationTracker = createPushNotificationTracker({
74
+ sessionId,
75
+ pushServiceClient,
76
+ previewMaxChars: config.pushPreviewMaxChars,
77
+ });
78
+
79
+ // Keep the local Codex runtime alive across transient relay disconnects.
80
+ let socket = null;
81
+ let isShuttingDown = false;
82
+ let reconnectAttempt = 0;
83
+ let reconnectTimer = null;
84
+ let lastConnectionStatus = null;
85
+ let codexHandshakeState = config.codexEndpoint ? "warm" : "cold";
86
+ const forwardedInitializeRequestIds = new Set();
87
+ const secureTransport = createBridgeSecureTransport({
88
+ sessionId,
89
+ relayUrl: relayBaseUrl,
90
+ deviceState,
91
+ onTrustedPhoneUpdate(nextDeviceState) {
92
+ deviceState = nextDeviceState;
93
+ sendRelayRegistrationUpdate(nextDeviceState);
94
+ },
95
+ });
96
+ // Keeps one stable sender identity across reconnects so buffered replay state
97
+ // reflects what actually made it onto the current relay socket.
98
+ function sendRelayWireMessage(wireMessage) {
99
+ if (socket?.readyState !== WebSocket.OPEN) {
100
+ return false;
101
+ }
102
+
103
+ socket.send(wireMessage);
104
+ return true;
105
+ }
106
+ // Only the spawned local runtime needs rollout mirroring; a real endpoint
107
+ // already provides the authoritative live stream for resumed threads.
108
+ const rolloutLiveMirror = !config.codexEndpoint
109
+ ? createRolloutLiveMirrorController({
110
+ sendApplicationResponse,
111
+ })
112
+ : null;
113
+ let contextUsageWatcher = null;
114
+ let watchedContextUsageKey = null;
115
+
116
+ const codex = createCodexTransport({
117
+ endpoint: config.codexEndpoint,
118
+ env: process.env,
119
+ logPrefix: "[codex-bridge]",
120
+ });
121
+ publishBridgeStatus({
122
+ state: "starting",
123
+ connectionStatus: "starting",
124
+ pid: process.pid,
125
+ lastError: "",
126
+ });
127
+
128
+ codex.onError((error) => {
129
+ publishBridgeStatus({
130
+ state: "error",
131
+ connectionStatus: "error",
132
+ pid: process.pid,
133
+ lastError: error.message,
134
+ });
135
+ if (config.codexEndpoint) {
136
+ console.error(`[codex-bridge] Failed to connect to Codex endpoint: ${config.codexEndpoint}`);
137
+ } else {
138
+ console.error("[codex-bridge] Failed to start `codex app-server`.");
139
+ console.error(`[codex-bridge] Launch command: ${codex.describe()}`);
140
+ console.error("[codex-bridge] Make sure the Codex CLI is installed and that the launcher works on this OS.");
141
+ }
142
+ console.error(error.message);
143
+ process.exit(1);
144
+ });
145
+
146
+ function clearReconnectTimer() {
147
+ if (!reconnectTimer) {
148
+ return;
149
+ }
150
+
151
+ clearTimeout(reconnectTimer);
152
+ reconnectTimer = null;
153
+ }
154
+
155
+ // Keeps npm start output compact by emitting only high-signal connection states.
156
+ function logConnectionStatus(status) {
157
+ if (lastConnectionStatus === status) {
158
+ return;
159
+ }
160
+
161
+ lastConnectionStatus = status;
162
+ publishBridgeStatus({
163
+ state: "running",
164
+ connectionStatus: status,
165
+ pid: process.pid,
166
+ lastError: "",
167
+ });
168
+ console.log(`[codex-bridge] ${status}`);
169
+ }
170
+
171
+ // Retries the relay socket while preserving the active Codex process and session id.
172
+ function scheduleRelayReconnect(closeCode) {
173
+ if (isShuttingDown) {
174
+ return;
175
+ }
176
+
177
+ if (closeCode === 4000 || closeCode === 4001) {
178
+ logConnectionStatus("disconnected");
179
+ shutdown(codex, () => socket, () => {
180
+ isShuttingDown = true;
181
+ clearReconnectTimer();
182
+ });
183
+ return;
184
+ }
185
+
186
+ if (reconnectTimer) {
187
+ return;
188
+ }
189
+
190
+ reconnectAttempt += 1;
191
+ const delayMs = Math.min(1_000 * reconnectAttempt, 5_000);
192
+ logConnectionStatus("connecting");
193
+ reconnectTimer = setTimeout(() => {
194
+ reconnectTimer = null;
195
+ connectRelay();
196
+ }, delayMs);
197
+ }
198
+
199
+ function connectRelay() {
200
+ if (isShuttingDown) {
201
+ return;
202
+ }
203
+
204
+ logConnectionStatus("connecting");
205
+ const nextSocket = new WebSocket(relaySessionUrl, {
206
+ // The relay uses this per-session secret to authenticate the first push registration.
207
+ headers: {
208
+ "x-role": "mac",
209
+ "x-notification-secret": notificationSecret,
210
+ ...buildMacRegistrationHeaders(deviceState),
211
+ },
212
+ });
213
+ socket = nextSocket;
214
+
215
+ nextSocket.on("open", () => {
216
+ clearReconnectTimer();
217
+ reconnectAttempt = 0;
218
+ logConnectionStatus("connected");
219
+ secureTransport.bindLiveSendWireMessage(sendRelayWireMessage);
220
+ sendRelayRegistrationUpdate(deviceState);
221
+ });
222
+
223
+ nextSocket.on("message", (data) => {
224
+ const message = typeof data === "string" ? data : data.toString("utf8");
225
+ if (secureTransport.handleIncomingWireMessage(message, {
226
+ sendControlMessage(controlMessage) {
227
+ if (nextSocket.readyState === WebSocket.OPEN) {
228
+ nextSocket.send(JSON.stringify(controlMessage));
229
+ }
230
+ },
231
+ onApplicationMessage(plaintextMessage) {
232
+ handleApplicationMessage(plaintextMessage);
233
+ },
234
+ })) {
235
+ return;
236
+ }
237
+ });
238
+
239
+ nextSocket.on("close", (code) => {
240
+ logConnectionStatus("disconnected");
241
+ if (socket === nextSocket) {
242
+ socket = null;
243
+ }
244
+ stopContextUsageWatcher();
245
+ rolloutLiveMirror?.stopAll();
246
+ desktopRefresher.handleTransportReset();
247
+ scheduleRelayReconnect(code);
248
+ });
249
+
250
+ nextSocket.on("error", () => {
251
+ logConnectionStatus("disconnected");
252
+ });
253
+ }
254
+
255
+ const pairingPayload = secureTransport.createPairingPayload();
256
+ onPairingPayload?.(pairingPayload);
257
+ if (printPairingQr) {
258
+ printQR(pairingPayload);
259
+ }
260
+ pushServiceClient.logUnavailable();
261
+ connectRelay();
262
+
263
+ codex.onMessage((message) => {
264
+ trackCodexHandshakeState(message);
265
+ desktopRefresher.handleOutbound(message);
266
+ pushNotificationTracker.handleOutbound(message);
267
+ rememberThreadFromMessage("codex", message);
268
+ secureTransport.queueOutboundApplicationMessage(message, sendRelayWireMessage);
269
+ });
270
+
271
+ codex.onClose(() => {
272
+ logConnectionStatus("disconnected");
273
+ publishBridgeStatus({
274
+ state: "stopped",
275
+ connectionStatus: "disconnected",
276
+ pid: process.pid,
277
+ lastError: "",
278
+ });
279
+ isShuttingDown = true;
280
+ clearReconnectTimer();
281
+ stopContextUsageWatcher();
282
+ rolloutLiveMirror?.stopAll();
283
+ desktopRefresher.handleTransportReset();
284
+ if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) {
285
+ socket.close();
286
+ }
287
+ });
288
+
289
+ process.on("SIGINT", () => shutdown(codex, () => socket, () => {
290
+ isShuttingDown = true;
291
+ clearReconnectTimer();
292
+ }));
293
+ process.on("SIGTERM", () => shutdown(codex, () => socket, () => {
294
+ isShuttingDown = true;
295
+ clearReconnectTimer();
296
+ }));
297
+
298
+ // Routes decrypted app payloads through the same bridge handlers as before.
299
+ function handleApplicationMessage(rawMessage) {
300
+ if (handleBridgeManagedHandshakeMessage(rawMessage)) {
301
+ return;
302
+ }
303
+ if (handleThreadContextRequest(rawMessage, sendApplicationResponse)) {
304
+ return;
305
+ }
306
+ if (handleWorkspaceRequest(rawMessage, sendApplicationResponse)) {
307
+ return;
308
+ }
309
+ if (notificationsHandler.handleNotificationsRequest(rawMessage, sendApplicationResponse)) {
310
+ return;
311
+ }
312
+ if (handleDesktopRequest(rawMessage, sendApplicationResponse, {
313
+ bundleId: config.codexBundleId,
314
+ appPath: config.codexAppPath,
315
+ })) {
316
+ return;
317
+ }
318
+ if (handleGitRequest(rawMessage, sendApplicationResponse)) {
319
+ return;
320
+ }
321
+ desktopRefresher.handleInbound(rawMessage);
322
+ rolloutLiveMirror?.observeInbound(rawMessage);
323
+ rememberThreadFromMessage("phone", rawMessage);
324
+ codex.send(rawMessage);
325
+ }
326
+
327
+ // Encrypts bridge-generated responses instead of letting the relay see plaintext.
328
+ function sendApplicationResponse(rawMessage) {
329
+ secureTransport.queueOutboundApplicationMessage(rawMessage, sendRelayWireMessage);
330
+ }
331
+
332
+ function rememberThreadFromMessage(source, rawMessage) {
333
+ const context = extractBridgeMessageContext(rawMessage);
334
+ if (!context.threadId) {
335
+ return;
336
+ }
337
+
338
+ rememberActiveThread(context.threadId, source);
339
+ if (shouldStartContextUsageWatcher(context)) {
340
+ ensureContextUsageWatcher(context);
341
+ }
342
+ }
343
+
344
+ // Mirrors CodexMonitor's persisted token_count fallback so the phone keeps
345
+ // receiving context-window usage even when the runtime omits live thread usage.
346
+ function ensureContextUsageWatcher({ threadId, turnId }) {
347
+ const normalizedThreadId = readString(threadId);
348
+ const normalizedTurnId = readString(turnId);
349
+ if (!normalizedThreadId) {
350
+ return;
351
+ }
352
+
353
+ const nextWatcherKey = `${normalizedThreadId}|${normalizedTurnId || "pending-turn"}`;
354
+ if (watchedContextUsageKey === nextWatcherKey && contextUsageWatcher) {
355
+ return;
356
+ }
357
+
358
+ stopContextUsageWatcher();
359
+ watchedContextUsageKey = nextWatcherKey;
360
+ contextUsageWatcher = createThreadRolloutActivityWatcher({
361
+ threadId: normalizedThreadId,
362
+ turnId: normalizedTurnId,
363
+ onUsage: ({ threadId: usageThreadId, usage }) => {
364
+ sendContextUsageNotification(usageThreadId, usage);
365
+ },
366
+ onIdle: () => {
367
+ if (watchedContextUsageKey === nextWatcherKey) {
368
+ stopContextUsageWatcher();
369
+ }
370
+ },
371
+ onTimeout: () => {
372
+ if (watchedContextUsageKey === nextWatcherKey) {
373
+ stopContextUsageWatcher();
374
+ }
375
+ },
376
+ onError: () => {
377
+ if (watchedContextUsageKey === nextWatcherKey) {
378
+ stopContextUsageWatcher();
379
+ }
380
+ },
381
+ });
382
+ }
383
+
384
+ function stopContextUsageWatcher() {
385
+ if (contextUsageWatcher) {
386
+ contextUsageWatcher.stop();
387
+ }
388
+
389
+ contextUsageWatcher = null;
390
+ watchedContextUsageKey = null;
391
+ }
392
+
393
+ function sendContextUsageNotification(threadId, usage) {
394
+ if (!threadId || !usage) {
395
+ return;
396
+ }
397
+
398
+ sendApplicationResponse(JSON.stringify({
399
+ method: "thread/tokenUsage/updated",
400
+ params: {
401
+ threadId,
402
+ usage,
403
+ },
404
+ }));
405
+ }
406
+
407
+ // The spawned/shared Codex app-server stays warm across phone reconnects.
408
+ // When iPhone reconnects it sends initialize again, but forwarding that to the
409
+ // already-initialized Codex transport only produces "Already initialized".
410
+ function handleBridgeManagedHandshakeMessage(rawMessage) {
411
+ let parsed = null;
412
+ try {
413
+ parsed = JSON.parse(rawMessage);
414
+ } catch {
415
+ return false;
416
+ }
417
+
418
+ const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
419
+ if (!method) {
420
+ return false;
421
+ }
422
+
423
+ if (method === "initialize" && parsed.id != null) {
424
+ if (codexHandshakeState !== "warm") {
425
+ forwardedInitializeRequestIds.add(String(parsed.id));
426
+ return false;
427
+ }
428
+
429
+ sendApplicationResponse(JSON.stringify({
430
+ id: parsed.id,
431
+ result: {
432
+ bridgeManaged: true,
433
+ },
434
+ }));
435
+ return true;
436
+ }
437
+
438
+ if (method === "initialized") {
439
+ return codexHandshakeState === "warm";
440
+ }
441
+
442
+ return false;
443
+ }
444
+
445
+ // Learns whether the underlying Codex transport has already completed its own MCP handshake.
446
+ function trackCodexHandshakeState(rawMessage) {
447
+ let parsed = null;
448
+ try {
449
+ parsed = JSON.parse(rawMessage);
450
+ } catch {
451
+ return;
452
+ }
453
+
454
+ const responseId = parsed?.id;
455
+ if (responseId == null) {
456
+ return;
457
+ }
458
+
459
+ const responseKey = String(responseId);
460
+ if (!forwardedInitializeRequestIds.has(responseKey)) {
461
+ return;
462
+ }
463
+
464
+ forwardedInitializeRequestIds.delete(responseKey);
465
+
466
+ if (parsed?.result != null) {
467
+ codexHandshakeState = "warm";
468
+ return;
469
+ }
470
+
471
+ const errorMessage = typeof parsed?.error?.message === "string"
472
+ ? parsed.error.message.toLowerCase()
473
+ : "";
474
+ if (errorMessage.includes("already initialized")) {
475
+ codexHandshakeState = "warm";
476
+ }
477
+ }
478
+
479
+ function publishBridgeStatus(status) {
480
+ onBridgeStatus?.(status);
481
+ }
482
+
483
+ // Refreshes the relay's trusted-mac index after the QR bootstrap locks in a phone identity.
484
+ function sendRelayRegistrationUpdate(nextDeviceState) {
485
+ deviceState = nextDeviceState;
486
+ if (socket?.readyState !== WebSocket.OPEN) {
487
+ return;
488
+ }
489
+
490
+ socket.send(JSON.stringify({
491
+ kind: "relayMacRegistration",
492
+ registration: buildMacRegistration(nextDeviceState),
493
+ }));
494
+ }
495
+ }
496
+
497
+ // Registers the canonical Mac identity and the one trusted iPhone allowed for auto-resolve.
498
+ function buildMacRegistrationHeaders(deviceState) {
499
+ const registration = buildMacRegistration(deviceState);
500
+ const headers = {
501
+ "x-mac-device-id": registration.macDeviceId,
502
+ "x-mac-identity-public-key": registration.macIdentityPublicKey,
503
+ "x-machine-name": registration.displayName,
504
+ };
505
+ if (registration.trustedPhoneDeviceId && registration.trustedPhonePublicKey) {
506
+ headers["x-trusted-phone-device-id"] = registration.trustedPhoneDeviceId;
507
+ headers["x-trusted-phone-public-key"] = registration.trustedPhonePublicKey;
508
+ }
509
+ return headers;
510
+ }
511
+
512
+ function buildMacRegistration(deviceState) {
513
+ const trustedPhoneEntry = Object.entries(deviceState?.trustedPhones || {})[0] || null;
514
+ return {
515
+ macDeviceId: normalizeNonEmptyString(deviceState?.macDeviceId),
516
+ macIdentityPublicKey: normalizeNonEmptyString(deviceState?.macIdentityPublicKey),
517
+ displayName: normalizeNonEmptyString(os.hostname()),
518
+ trustedPhoneDeviceId: normalizeNonEmptyString(trustedPhoneEntry?.[0]),
519
+ trustedPhonePublicKey: normalizeNonEmptyString(trustedPhoneEntry?.[1]),
520
+ };
521
+ }
522
+
523
+ function shutdown(codex, getSocket, beforeExit = () => {}) {
524
+ beforeExit();
525
+
526
+ const socket = getSocket();
527
+ if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) {
528
+ socket.close();
529
+ }
530
+
531
+ codex.shutdown();
532
+
533
+ setTimeout(() => process.exit(0), 100);
534
+ }
535
+
536
+ function extractBridgeMessageContext(rawMessage) {
537
+ let parsed = null;
538
+ try {
539
+ parsed = JSON.parse(rawMessage);
540
+ } catch {
541
+ return { method: "", threadId: null, turnId: null };
542
+ }
543
+
544
+ const method = parsed?.method;
545
+ const params = parsed?.params;
546
+ const threadId = extractThreadId(method, params);
547
+ const turnId = extractTurnId(method, params);
548
+
549
+ return {
550
+ method: typeof method === "string" ? method : "",
551
+ threadId,
552
+ turnId,
553
+ };
554
+ }
555
+
556
+ function shouldStartContextUsageWatcher(context) {
557
+ if (!context?.threadId) {
558
+ return false;
559
+ }
560
+
561
+ return context.method === "turn/start"
562
+ || context.method === "turn/started";
563
+ }
564
+
565
+ function extractThreadId(method, params) {
566
+ if (method === "turn/start" || method === "turn/started") {
567
+ return (
568
+ readString(params?.threadId)
569
+ || readString(params?.thread_id)
570
+ || readString(params?.turn?.threadId)
571
+ || readString(params?.turn?.thread_id)
572
+ );
573
+ }
574
+
575
+ if (method === "thread/start" || method === "thread/started") {
576
+ return (
577
+ readString(params?.threadId)
578
+ || readString(params?.thread_id)
579
+ || readString(params?.thread?.id)
580
+ || readString(params?.thread?.threadId)
581
+ || readString(params?.thread?.thread_id)
582
+ );
583
+ }
584
+
585
+ if (method === "turn/completed") {
586
+ return (
587
+ readString(params?.threadId)
588
+ || readString(params?.thread_id)
589
+ || readString(params?.turn?.threadId)
590
+ || readString(params?.turn?.thread_id)
591
+ );
592
+ }
593
+
594
+ return null;
595
+ }
596
+
597
+ function extractTurnId(method, params) {
598
+ if (method === "turn/started" || method === "turn/completed") {
599
+ return (
600
+ readString(params?.turnId)
601
+ || readString(params?.turn_id)
602
+ || readString(params?.id)
603
+ || readString(params?.turn?.id)
604
+ || readString(params?.turn?.turnId)
605
+ || readString(params?.turn?.turn_id)
606
+ );
607
+ }
608
+
609
+ return null;
610
+ }
611
+
612
+ function readString(value) {
613
+ return typeof value === "string" && value ? value : null;
614
+ }
615
+
616
+ function normalizeNonEmptyString(value) {
617
+ return typeof value === "string" && value.trim() ? value.trim() : "";
618
+ }
619
+
620
+ module.exports = { startBridge };