codex-relay 1.0.4 → 1.0.5

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
@@ -16,7 +16,7 @@ Run the server from the workspace you want Codex to use:
16
16
  npx codex-relay@latest
17
17
  ```
18
18
 
19
- The CLI prints a QR code, a mobile URL, and a `codex-relay://pair...` pairing payload. Scan the QR code from the mobile app. If scanning is not available, paste the full pairing payload into the app.
19
+ The CLI prints a QR code, a mobile URL, and a `codex-relay://pair...` pairing payload. Scan the QR code from the mobile app. If the relay detects multiple possible network addresses, the QR includes them and the app automatically uses the first address it can reach. If scanning is not available, paste the full pairing payload into the app.
20
20
 
21
21
  When the app shows an approval code, approve it on the computer:
22
22
 
@@ -117,10 +117,11 @@ CODEX_RELAY_WORKSPACE_PATH=/path/to/project npx codex-relay@latest
117
117
 
118
118
  ## Network Notes
119
119
 
120
- The phone must be able to reach the URL printed as `Mobile:` by the relay.
120
+ The phone must be able to reach one of the URLs printed by the relay.
121
121
 
122
122
  - On the same Wi-Fi network, the relay usually prints a local network address.
123
123
  - On Tailscale, the relay prefers your Tailscale address when it can detect one.
124
+ - If several Wi-Fi, VPN, or virtual network addresses are available, the QR includes all detected candidates and the app tries them automatically.
124
125
  - If the printed URL is not reachable from the phone, set `CODEX_RELAY_PUBLIC_URL` to a reachable HTTP URL.
125
126
 
126
127
  ## Troubleshooting
@@ -640,6 +640,7 @@ const apiPaths = {
640
640
  pair: "/v1/pair",
641
641
  pairApproval: (approvalCode) => `/v1/pair/${encodeURIComponent(approvalCode)}`,
642
642
  pairApprove: "/v1/pair/approve",
643
+ sessionsClear: "/v1/sessions/clear",
643
644
  sessionRefresh: "/v1/session/refresh",
644
645
  status: "/v1/status",
645
646
  preferences: "/v1/preferences",
package/dist/cli.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { Dt as apiPaths } from "./api-schema2.js";
3
- import { n as codexRelayHome, r as legacyCodexRelayDataPath, t as codexRelayDataPath } from "./paths.js";
3
+ import { n as codexRelayHome, o as getConnectUrlGuidance, r as legacyCodexRelayDataPath, s as createTursoPairingSessionStore, t as codexRelayDataPath } from "./paths.js";
4
4
  import { Command } from "@commander-js/extra-typings";
5
5
  import qrcode from "qrcode-terminal";
6
6
  import { spawn } from "node:child_process";
7
7
  import { closeSync, openSync } from "node:fs";
8
- import { mkdir, readFile, unlink } from "node:fs/promises";
8
+ import { access, mkdir, readFile, rm, unlink } from "node:fs/promises";
9
9
  import { dirname } from "node:path";
10
10
  import { setTimeout } from "node:timers/promises";
11
11
  import { fileURLToPath } from "node:url";
@@ -17,6 +17,7 @@ Examples:
17
17
  ${npxCommand} Start the relay and print a pairing QR
18
18
  ${npxCommand} --bg Start the relay in the background
19
19
  ${npxCommand} qr Print the current pairing QR
20
+ ${npxCommand} clear Sign out every paired mobile app
20
21
  ${npxCommand} approve CODE Approve a pending mobile pairing request`).action(async (options) => {
21
22
  if (options.debug) process.env.CODEX_RELAY_DEBUG = "1";
22
23
  if (options.dangerouslyAutoApprove) process.env.CODEX_RELAY_DANGEROUSLY_AUTO_APPROVE = "1";
@@ -32,6 +33,9 @@ program.command("qr").description("Print the current pairing QR for an already r
32
33
  program.command("approve").description("Approve a pending mobile pairing request.").argument("<approval-code>", "approval code shown in the mobile app").action(async (approvalCode) => {
33
34
  await approvePairing(approvalCode);
34
35
  });
36
+ program.command("clear").description("Sign out every paired mobile app.").option("--debug", "also delete debug.log").action(async (options, command) => {
37
+ await clearPairings({ clearDebugLog: Boolean(options.debug || command.optsWithGlobals().debug) });
38
+ });
35
39
  await program.parseAsync();
36
40
  async function startBackgroundServer() {
37
41
  const logPath = codexRelayDataPath("server.log");
@@ -135,6 +139,74 @@ async function approvePairing(rawCode) {
135
139
  }
136
140
  console.log("Approved Codex Relay pairing request.");
137
141
  }
142
+ async function clearPairings(options) {
143
+ const result = await clearPairingsViaServer().catch(async (error) => {
144
+ if (await hasRunningBackgroundServer()) throw error;
145
+ return clearPairingsFromLocalStore();
146
+ });
147
+ const removedDebugLogs = options.clearDebugLog ? await clearDebugLogs() : [];
148
+ console.log(`Signed out ${result.sessionsCleared} paired mobile app${result.sessionsCleared === 1 ? "" : "s"}.`);
149
+ if (result.pendingPairingsCleared > 0) console.log(`Removed ${result.pendingPairingsCleared} pending pairing request${result.pendingPairingsCleared === 1 ? "" : "s"}.`);
150
+ if (options.clearDebugLog) console.log(removedDebugLogs.length > 0 ? `Deleted debug logs: ${removedDebugLogs.join(", ")}` : "No debug logs found.");
151
+ }
152
+ async function clearPairingsViaServer() {
153
+ const endpoint = await getApprovalEndpoint();
154
+ const secret = await readApprovalSecret();
155
+ const response = await fetch(`${endpoint}${apiPaths.sessionsClear}`, {
156
+ method: "POST",
157
+ headers: {
158
+ accept: "application/json",
159
+ "x-codex-relay-approve-secret": secret
160
+ }
161
+ });
162
+ const payload = await response.json().catch(() => void 0);
163
+ if (!response.ok) {
164
+ const message = payload && typeof payload === "object" && "error" in payload && payload.error && typeof payload.error === "object" && "message" in payload.error ? String(payload.error.message) : `Codex Relay server returned ${response.status}`;
165
+ throw new Error(message);
166
+ }
167
+ return parseClearPairingResult(payload);
168
+ }
169
+ async function clearPairingsFromLocalStore() {
170
+ const dbPath = await resolveAuthDbPath();
171
+ if (!dbPath) return {
172
+ pendingPairingsCleared: 0,
173
+ sessionsCleared: 0
174
+ };
175
+ return (await createTursoPairingSessionStore(dbPath)).clearAll();
176
+ }
177
+ async function resolveAuthDbPath() {
178
+ if (process.env.CODEX_RELAY_AUTH_DB_PATH) return process.env.CODEX_RELAY_AUTH_DB_PATH;
179
+ const primary = codexRelayDataPath("auth.db");
180
+ if (await pathExists(primary)) return primary;
181
+ const legacy = legacyCodexRelayDataPath("auth.db");
182
+ if (await pathExists(legacy)) return legacy;
183
+ }
184
+ async function clearDebugLogs() {
185
+ const paths = [...new Set([codexRelayDataPath("debug.log"), legacyCodexRelayDataPath("debug.log")])];
186
+ const removed = [];
187
+ for (const path of paths) {
188
+ if (!await pathExists(path)) continue;
189
+ await rm(path, { force: true });
190
+ removed.push(path);
191
+ }
192
+ return removed;
193
+ }
194
+ async function hasRunningBackgroundServer() {
195
+ return Boolean(await readRunningPid(codexRelayDataPath("server.pid")));
196
+ }
197
+ async function pathExists(path) {
198
+ return access(path).then(() => true, () => false);
199
+ }
200
+ function parseClearPairingResult(payload) {
201
+ if (!payload || typeof payload !== "object") return {
202
+ pendingPairingsCleared: 0,
203
+ sessionsCleared: 0
204
+ };
205
+ return {
206
+ pendingPairingsCleared: "pendingPairingsCleared" in payload ? Number(payload.pendingPairingsCleared) || 0 : 0,
207
+ sessionsCleared: "sessionsCleared" in payload ? Number(payload.sessionsCleared) || 0 : 0
208
+ };
209
+ }
138
210
  async function printPairingQr() {
139
211
  const storedState = await readServerState();
140
212
  const state = storedState?.pairingPayload ? storedState : await readServerLogState();
@@ -148,7 +220,15 @@ async function printPairingQr() {
148
220
  console.log("");
149
221
  qrcode.generate(state.pairingPayload, { small: true });
150
222
  console.log("");
151
- if (state.connectUrl) console.log(`Mobile: ${state.connectUrl}`);
223
+ if (state.connectUrl) {
224
+ console.log(`Mobile: ${state.connectUrl}`);
225
+ const guidance = getConnectUrlGuidance(state.connectUrl);
226
+ if (guidance) console.log(`Network: ${guidance}`);
227
+ }
228
+ if (state.connectUrlCandidates && state.connectUrlCandidates.length > 1) {
229
+ console.log(`Candidate addresses: ${state.connectUrlCandidates.length}`);
230
+ for (const candidate of state.connectUrlCandidates.slice(1)) console.log(` ${candidate.label}: ${candidate.url}`);
231
+ }
152
232
  if (state.listenUrl) console.log(`Server: ${state.listenUrl}`);
153
233
  console.log("");
154
234
  console.log(`Pairing: ${state.pairingPayload}`);
@@ -206,6 +286,7 @@ async function readServerState() {
206
286
  if (!state) return;
207
287
  return {
208
288
  connectUrl: typeof state.connectUrl === "string" ? state.connectUrl : void 0,
289
+ connectUrlCandidates: parseConnectUrlCandidates(state.connectUrlCandidates),
209
290
  host: typeof state.host === "string" ? state.host : void 0,
210
291
  listenUrl: typeof state.listenUrl === "string" ? state.listenUrl : void 0,
211
292
  pairingPayload: typeof state.pairingPayload === "string" ? state.pairingPayload : void 0,
@@ -235,6 +316,18 @@ function lastLogValue(log, label) {
235
316
  for (const match of log.matchAll(pattern)) value = match[1];
236
317
  return value;
237
318
  }
319
+ function parseConnectUrlCandidates(value) {
320
+ if (!Array.isArray(value)) return;
321
+ return value.map((candidate) => {
322
+ if (!candidate || typeof candidate !== "object") return;
323
+ const label = "label" in candidate ? candidate.label : void 0;
324
+ const url = "url" in candidate ? candidate.url : void 0;
325
+ return typeof label === "string" && typeof url === "string" ? {
326
+ label,
327
+ url
328
+ } : void 0;
329
+ }).filter((candidate) => Boolean(candidate));
330
+ }
238
331
  function isDatabaseLockError(error) {
239
332
  const message = error instanceof Error ? error.message : String(error);
240
333
  return message.includes("failed to open database") && message.includes("Locking error");
package/dist/paths.js CHANGED
@@ -1,5 +1,427 @@
1
- import { join, resolve } from "node:path";
2
- import { homedir, platform } from "node:os";
1
+ import { execFileSync } from "node:child_process";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { connect } from "@tursodatabase/database";
5
+ import { fromByteArray, toByteArray } from "base64-js";
6
+ import { homedir, networkInterfaces, platform } from "node:os";
7
+ //#region src/pairing-store.ts
8
+ async function createTursoPairingSessionStore(path) {
9
+ if (path !== ":memory:") await mkdir(dirname(path), { recursive: true });
10
+ const db = await connect(path);
11
+ await db.exec(`
12
+ CREATE TABLE IF NOT EXISTS pairing_sessions (
13
+ token_hash TEXT PRIMARY KEY,
14
+ client_session_id TEXT,
15
+ client_name TEXT,
16
+ expires_at INTEGER NOT NULL,
17
+ key_epoch INTEGER,
18
+ mobile_to_server_key TEXT,
19
+ server_to_mobile_key TEXT,
20
+ last_mobile_counter INTEGER,
21
+ next_server_counter INTEGER,
22
+ created_at INTEGER NOT NULL,
23
+ updated_at INTEGER NOT NULL
24
+ );
25
+
26
+ CREATE TABLE IF NOT EXISTS pending_pairings (
27
+ approval_code TEXT PRIMARY KEY,
28
+ client_session_id TEXT,
29
+ client_name TEXT,
30
+ client_ephemeral_public_key TEXT NOT NULL,
31
+ client_nonce TEXT NOT NULL,
32
+ server_url TEXT NOT NULL,
33
+ approved INTEGER NOT NULL DEFAULT 0,
34
+ expires_at INTEGER NOT NULL,
35
+ created_at INTEGER NOT NULL,
36
+ updated_at INTEGER NOT NULL
37
+ );
38
+ `);
39
+ await ensurePairingSessionColumns();
40
+ async function countActive(now) {
41
+ const row = await db.prepare("SELECT COUNT(DISTINCT COALESCE(client_session_id, token_hash)) AS count FROM pairing_sessions WHERE expires_at > ?").get(now);
42
+ return Number(row?.count ?? 0);
43
+ }
44
+ async function deleteSession(tokenHash) {
45
+ await db.prepare("DELETE FROM pairing_sessions WHERE token_hash = ?").run(tokenHash);
46
+ }
47
+ async function deletePendingPairing(approvalCode) {
48
+ await db.prepare("DELETE FROM pending_pairings WHERE approval_code = ?").run(approvalCode);
49
+ }
50
+ async function getPendingPairing(approvalCode, now) {
51
+ const row = await db.prepare(`SELECT approval_code AS approvalCode,
52
+ client_session_id AS clientSessionId,
53
+ client_name AS clientName,
54
+ client_ephemeral_public_key AS clientEphemeralPublicKey,
55
+ client_nonce AS clientNonce,
56
+ server_url AS serverUrl,
57
+ approved,
58
+ expires_at AS expiresAt
59
+ FROM pending_pairings
60
+ WHERE approval_code = ?`).get(approvalCode);
61
+ if (!row) return;
62
+ const expiresAt = Number(row.expiresAt);
63
+ if (now > expiresAt) {
64
+ await deletePendingPairing(approvalCode);
65
+ return;
66
+ }
67
+ return {
68
+ approvalCode: String(row.approvalCode),
69
+ approved: Number(row.approved) === 1,
70
+ clientEphemeralPublicKey: String(row.clientEphemeralPublicKey),
71
+ clientSessionId: typeof row.clientSessionId === "string" ? row.clientSessionId : void 0,
72
+ clientName: typeof row.clientName === "string" ? row.clientName : void 0,
73
+ clientNonce: String(row.clientNonce),
74
+ expiresAt,
75
+ serverUrl: String(row.serverUrl)
76
+ };
77
+ }
78
+ return {
79
+ async approvePendingPairing(approvalCode, now) {
80
+ const pending = await getPendingPairing(approvalCode, now);
81
+ if (!pending) return;
82
+ await db.prepare("UPDATE pending_pairings SET approved = 1, updated_at = ? WHERE approval_code = ?").run(now, approvalCode);
83
+ return {
84
+ ...pending,
85
+ approved: true
86
+ };
87
+ },
88
+ async clearAll() {
89
+ return await db.transaction(async () => {
90
+ const sessionRow = await db.prepare("SELECT COUNT(*) AS count FROM pairing_sessions").get();
91
+ const pendingRow = await db.prepare("SELECT COUNT(*) AS count FROM pending_pairings").get();
92
+ await db.prepare("DELETE FROM pairing_sessions").run();
93
+ await db.prepare("DELETE FROM pending_pairings").run();
94
+ return {
95
+ pendingPairingsCleared: Number(pendingRow?.count ?? 0),
96
+ sessionsCleared: Number(sessionRow?.count ?? 0)
97
+ };
98
+ })();
99
+ },
100
+ countActive,
101
+ async createPendingPairing(pairing) {
102
+ const now = Date.now();
103
+ await db.prepare(`INSERT INTO pending_pairings (
104
+ approval_code,
105
+ client_session_id,
106
+ client_name,
107
+ client_ephemeral_public_key,
108
+ client_nonce,
109
+ server_url,
110
+ approved,
111
+ expires_at,
112
+ created_at,
113
+ updated_at
114
+ )
115
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(pairing.approvalCode, pairing.clientSessionId ?? null, pairing.clientName ?? null, pairing.clientEphemeralPublicKey, pairing.clientNonce, pairing.serverUrl, pairing.approved ? 1 : 0, pairing.expiresAt, now, now);
116
+ },
117
+ async createSession(tokenHash, session) {
118
+ const now = Date.now();
119
+ const secure = encodeSecureSession(session.secureSession);
120
+ if (session.clientSessionId) {
121
+ await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id = ?").run(session.clientSessionId);
122
+ if (session.clientName) await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id IS NULL AND client_name = ?").run(session.clientName);
123
+ }
124
+ await db.prepare(`INSERT INTO pairing_sessions (
125
+ token_hash,
126
+ client_session_id,
127
+ client_name,
128
+ expires_at,
129
+ key_epoch,
130
+ mobile_to_server_key,
131
+ server_to_mobile_key,
132
+ last_mobile_counter,
133
+ next_server_counter,
134
+ created_at,
135
+ updated_at
136
+ )
137
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(tokenHash, session.clientSessionId ?? null, session.clientName ?? null, session.expiresAt, secure?.keyEpoch ?? null, secure?.mobileToServerKey ?? null, secure?.serverToMobileKey ?? null, secure?.lastMobileCounter ?? null, secure?.nextServerCounter ?? null, now, now);
138
+ return countActive(now);
139
+ },
140
+ deleteSession,
141
+ deletePendingPairing,
142
+ getPendingPairing,
143
+ async getValidSession(tokenHash, now) {
144
+ const row = await db.prepare(`SELECT client_name AS clientName,
145
+ client_session_id AS clientSessionId,
146
+ expires_at AS expiresAt,
147
+ key_epoch AS keyEpoch,
148
+ mobile_to_server_key AS mobileToServerKey,
149
+ server_to_mobile_key AS serverToMobileKey,
150
+ last_mobile_counter AS lastMobileCounter,
151
+ next_server_counter AS nextServerCounter
152
+ FROM pairing_sessions
153
+ WHERE token_hash = ?`).get(tokenHash);
154
+ if (!row) return;
155
+ const expiresAt = Number(row.expiresAt);
156
+ if (now > expiresAt) {
157
+ await deleteSession(tokenHash);
158
+ return;
159
+ }
160
+ return {
161
+ clientSessionId: typeof row.clientSessionId === "string" ? row.clientSessionId : void 0,
162
+ clientName: typeof row.clientName === "string" ? row.clientName : void 0,
163
+ expiresAt,
164
+ secureSession: decodeSecureSession(row)
165
+ };
166
+ },
167
+ async pruneExpired(now) {
168
+ await db.prepare("DELETE FROM pairing_sessions WHERE expires_at <= ?").run(now);
169
+ await db.prepare("DELETE FROM pending_pairings WHERE expires_at <= ?").run(now);
170
+ },
171
+ async rotateSession(oldTokenHash, newTokenHash, session) {
172
+ const now = Date.now();
173
+ const secure = encodeSecureSession(session.secureSession);
174
+ await db.transaction(async () => {
175
+ await db.prepare("DELETE FROM pairing_sessions WHERE token_hash = ?").run(oldTokenHash);
176
+ if (session.clientSessionId) {
177
+ await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id = ?").run(session.clientSessionId);
178
+ if (session.clientName) await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id IS NULL AND client_name = ?").run(session.clientName);
179
+ }
180
+ await db.prepare(`INSERT INTO pairing_sessions (
181
+ token_hash,
182
+ client_session_id,
183
+ client_name,
184
+ expires_at,
185
+ key_epoch,
186
+ mobile_to_server_key,
187
+ server_to_mobile_key,
188
+ last_mobile_counter,
189
+ next_server_counter,
190
+ created_at,
191
+ updated_at
192
+ )
193
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newTokenHash, session.clientSessionId ?? null, session.clientName ?? null, session.expiresAt, secure?.keyEpoch ?? null, secure?.mobileToServerKey ?? null, secure?.serverToMobileKey ?? null, secure?.lastMobileCounter ?? null, secure?.nextServerCounter ?? null, now, now);
194
+ })();
195
+ return countActive(now);
196
+ },
197
+ async updateSecureSession(tokenHash, secureSession) {
198
+ const secure = encodeSecureSession(secureSession);
199
+ const now = Date.now();
200
+ await db.prepare(`UPDATE pairing_sessions
201
+ SET key_epoch = ?,
202
+ mobile_to_server_key = ?,
203
+ server_to_mobile_key = ?,
204
+ last_mobile_counter = ?,
205
+ next_server_counter = ?,
206
+ updated_at = ?
207
+ WHERE token_hash = ?`).run(secure.keyEpoch, secure.mobileToServerKey, secure.serverToMobileKey, secure.lastMobileCounter, secure.nextServerCounter, now, tokenHash);
208
+ }
209
+ };
210
+ async function ensurePairingSessionColumns() {
211
+ const rows = await db.prepare("PRAGMA table_info(pairing_sessions)").all();
212
+ const columns = new Set(resultRows(rows).map((row) => String(row.name)));
213
+ for (const [column, sql] of [
214
+ ["client_session_id", "ALTER TABLE pairing_sessions ADD COLUMN client_session_id TEXT"],
215
+ ["key_epoch", "ALTER TABLE pairing_sessions ADD COLUMN key_epoch INTEGER"],
216
+ ["mobile_to_server_key", "ALTER TABLE pairing_sessions ADD COLUMN mobile_to_server_key TEXT"],
217
+ ["server_to_mobile_key", "ALTER TABLE pairing_sessions ADD COLUMN server_to_mobile_key TEXT"],
218
+ ["last_mobile_counter", "ALTER TABLE pairing_sessions ADD COLUMN last_mobile_counter INTEGER"],
219
+ ["next_server_counter", "ALTER TABLE pairing_sessions ADD COLUMN next_server_counter INTEGER"]
220
+ ]) if (!columns.has(column)) await db.exec(sql);
221
+ const pendingRows = await db.prepare("PRAGMA table_info(pending_pairings)").all();
222
+ if (!new Set(resultRows(pendingRows).map((row) => String(row.name))).has("client_session_id")) await db.exec("ALTER TABLE pending_pairings ADD COLUMN client_session_id TEXT");
223
+ }
224
+ }
225
+ function encodeSecureSession(session) {
226
+ if (!session) return;
227
+ return {
228
+ keyEpoch: session.keyEpoch,
229
+ lastMobileCounter: session.lastMobileCounter,
230
+ mobileToServerKey: fromByteArray(session.mobileToServerKey),
231
+ nextServerCounter: session.nextServerCounter,
232
+ serverToMobileKey: fromByteArray(session.serverToMobileKey)
233
+ };
234
+ }
235
+ function decodeSecureSession(row) {
236
+ if (typeof row.mobileToServerKey !== "string" || typeof row.serverToMobileKey !== "string" || row.keyEpoch === null || row.lastMobileCounter === null || row.nextServerCounter === null) return;
237
+ return {
238
+ keyEpoch: Number(row.keyEpoch),
239
+ lastMobileCounter: Number(row.lastMobileCounter),
240
+ mobileToServerKey: toByteArray(row.mobileToServerKey),
241
+ nextServerCounter: Number(row.nextServerCounter),
242
+ serverToMobileKey: toByteArray(row.serverToMobileKey)
243
+ };
244
+ }
245
+ function resultRows(result) {
246
+ if (Array.isArray(result)) return result;
247
+ if (result && typeof result === "object" && Array.isArray(result.rows)) return result.rows;
248
+ return [];
249
+ }
250
+ //#endregion
251
+ //#region src/pairing-url-candidates.ts
252
+ function getConnectUrlGuidance(url) {
253
+ const host = parseUrlHost(url);
254
+ if (!host) return;
255
+ if (isLocalhost(host) || isUnspecifiedHost(host)) return "This address is only reachable from this computer. Use a same-Wi-Fi address, Tailscale, or CODEX_RELAY_PUBLIC_URL for mobile pairing.";
256
+ if (isTailscaleHost(host)) return "Using Tailscale. Keep Tailscale connected on both this computer and the phone.";
257
+ if (isPrivateIPv4Host(host) || isLocalIPv6Host(host)) return "Using a local Wi-Fi/LAN address. Keep the phone and computer on the same network; if pairing is flaky, try Tailscale.";
258
+ return "Using a configured or public address. Make sure the phone can reach it before pairing.";
259
+ }
260
+ function createPairingQrPayload(details) {
261
+ const primaryServerUrl = details.serverUrls[0];
262
+ if (!primaryServerUrl) throw new Error("Pairing QR requires at least one server URL.");
263
+ const url = new URL("codex-relay://pair");
264
+ url.searchParams.set("serverUrl", primaryServerUrl);
265
+ url.searchParams.set("serverPublicKey", details.serverPublicKey);
266
+ const hosts = compactCandidateHosts(primaryServerUrl, details.serverUrls);
267
+ if (hosts.length > 0) url.searchParams.set("h", hosts.join(","));
268
+ return url.toString();
269
+ }
270
+ function getConnectUrlCandidates(details) {
271
+ return dedupeCandidates([
272
+ ...configuredConnectUrlCandidates(),
273
+ ...tailscaleConnectUrlCandidates(details.port),
274
+ ...localNetworkConnectUrlCandidates(details.port),
275
+ {
276
+ label: "Server",
277
+ url: details.listenUrl
278
+ }
279
+ ]);
280
+ }
281
+ function normalizeUrl(value) {
282
+ if (!value) return;
283
+ const trimmed = value.trim().replace(/\/$/, "");
284
+ if (!trimmed) return;
285
+ try {
286
+ const url = new URL(trimmed);
287
+ return url.protocol === "http:" || url.protocol === "https:" ? url.toString().replace(/\/$/, "") : void 0;
288
+ } catch {
289
+ return;
290
+ }
291
+ }
292
+ function configuredConnectUrlCandidates() {
293
+ const configuredUrl = normalizeUrl(process.env.CODEX_RELAY_PUBLIC_URL);
294
+ return configuredUrl ? [{
295
+ label: "Configured",
296
+ url: configuredUrl
297
+ }] : [];
298
+ }
299
+ function tailscaleConnectUrlCandidates(port) {
300
+ const status = getTailscaleStatus();
301
+ const candidates = [];
302
+ for (const ip of status?.Self?.TailscaleIPs ?? []) if (ip.startsWith("100.") && ip.includes(".")) candidates.push({
303
+ label: "Tailscale",
304
+ url: `http://${ip}:${port}`
305
+ });
306
+ const dnsName = status?.Self?.DNSName?.replace(/\.$/, "");
307
+ if (dnsName) {
308
+ const servedUrl = getTailscaleServeHttpsUrl(dnsName, port);
309
+ candidates.push({
310
+ label: servedUrl ? "Tailscale Serve" : "Tailscale DNS",
311
+ url: servedUrl ?? `http://${dnsName}:${port}`
312
+ });
313
+ }
314
+ for (const ip of status?.Self?.TailscaleIPs ?? []) if (ip.includes(".")) candidates.push({
315
+ label: "Tailscale",
316
+ url: `http://${ip}:${port}`
317
+ });
318
+ return candidates;
319
+ }
320
+ function localNetworkConnectUrlCandidates(port) {
321
+ const candidates = [];
322
+ for (const [name, addresses] of Object.entries(networkInterfaces())) for (const address of addresses ?? []) if (address.family === "IPv4" && !address.internal) candidates.push({
323
+ label: name,
324
+ url: `http://${address.address}:${port}`
325
+ });
326
+ return candidates;
327
+ }
328
+ function dedupeCandidates(candidates) {
329
+ const deduped = /* @__PURE__ */ new Map();
330
+ for (const candidate of candidates) {
331
+ const url = normalizeUrl(candidate.url);
332
+ if (url && !deduped.has(url)) deduped.set(url, {
333
+ ...candidate,
334
+ url
335
+ });
336
+ }
337
+ return [...deduped.values()];
338
+ }
339
+ function compactCandidateHosts(primaryServerUrl, serverUrls) {
340
+ const primary = parseUrl(primaryServerUrl);
341
+ if (!primary) return [];
342
+ const hosts = [];
343
+ for (const serverUrl of serverUrls.slice(1)) {
344
+ const candidate = parseUrl(serverUrl);
345
+ if (candidate && candidate.protocol === primary.protocol && candidate.port === primary.port && !hosts.includes(candidate.hostname)) hosts.push(candidate.hostname);
346
+ }
347
+ return hosts;
348
+ }
349
+ function parseUrl(url) {
350
+ try {
351
+ return new URL(url);
352
+ } catch {
353
+ return;
354
+ }
355
+ }
356
+ function parseUrlHost(url) {
357
+ try {
358
+ return new URL(url).hostname.toLowerCase();
359
+ } catch {
360
+ return;
361
+ }
362
+ }
363
+ function isLocalhost(host) {
364
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
365
+ }
366
+ function isUnspecifiedHost(host) {
367
+ return host === "0.0.0.0" || host === "::";
368
+ }
369
+ function isTailscaleHost(host) {
370
+ return host.endsWith(".ts.net") || host.endsWith(".beta.tailscale.net") || isTailscaleIPv4Host(host);
371
+ }
372
+ function isPrivateIPv4Host(host) {
373
+ const octets = host.split(".").map(Number);
374
+ if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet))) return false;
375
+ return octets[0] === 10 || octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31 || octets[0] === 192 && octets[1] === 168 || octets[0] === 169 && octets[1] === 254;
376
+ }
377
+ function isTailscaleIPv4Host(host) {
378
+ const octets = host.split(".").map(Number);
379
+ return octets.length === 4 && octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127;
380
+ }
381
+ function isLocalIPv6Host(host) {
382
+ const normalized = host.replace(/^\[/, "").replace(/\]$/, "");
383
+ return normalized.startsWith("fe80:") || normalized.startsWith("fc") || normalized.startsWith("fd");
384
+ }
385
+ function getTailscaleStatus() {
386
+ try {
387
+ const output = execFileSync("tailscale", ["status", "--json"], {
388
+ encoding: "utf8",
389
+ stdio: [
390
+ "ignore",
391
+ "pipe",
392
+ "ignore"
393
+ ],
394
+ timeout: 1500
395
+ });
396
+ return JSON.parse(output);
397
+ } catch {
398
+ return;
399
+ }
400
+ }
401
+ function getTailscaleServeHttpsUrl(dnsName, port) {
402
+ try {
403
+ const output = execFileSync("tailscale", [
404
+ "serve",
405
+ "status",
406
+ "--json"
407
+ ], {
408
+ encoding: "utf8",
409
+ stdio: [
410
+ "ignore",
411
+ "pipe",
412
+ "ignore"
413
+ ],
414
+ timeout: 1500
415
+ });
416
+ const serveStatus = JSON.parse(output);
417
+ const portKey = String(port);
418
+ const hostPort = `${dnsName}:${portKey}`;
419
+ return serveStatus.TCP?.[portKey]?.HTTPS && serveStatus.Web?.[hostPort] ? `https://${hostPort}` : void 0;
420
+ } catch {
421
+ return;
422
+ }
423
+ }
424
+ //#endregion
3
425
  //#region src/paths.ts
4
426
  const appDataDirectoryName = "codex-relay";
5
427
  function codexRelayHome() {
@@ -22,4 +444,4 @@ function defaultCodexRelayHome() {
22
444
  }
23
445
  }
24
446
  //#endregion
25
- export { codexRelayHome as n, legacyCodexRelayDataPath as r, codexRelayDataPath as t };
447
+ export { getConnectUrlCandidates as a, createPairingQrPayload as i, codexRelayHome as n, getConnectUrlGuidance as o, legacyCodexRelayDataPath as r, createTursoPairingSessionStore as s, codexRelayDataPath as t };
package/dist/src.js CHANGED
@@ -1,16 +1,16 @@
1
1
  import { A as PairResponseSchema, C as ListQueuedThreadInputsResponseSchema, D as ListWorkspaceFilesResponseSchema, Dt as apiPaths, E as ListWorkspaceDirectoriesResponseSchema, G as ResolveApprovalRequestSchema, K as ResolveApprovalResponseSchema, Lt as stripPromptSkillMentions, Mt as promptMarkdownWithSkills, Ot as chatMessageDetailsFromPromptContext, Q as RuntimePreferencesSchema, S as ListModelsResponseSchema, St as WorkspaceGitActionResponseSchema, T as ListThreadsResponseSchema, U as RateLimitsResponseSchema, X as RuntimePreferencesByWorkspacePathSchema, Z as RuntimePreferencesResponseSchema, _ as EncryptedPayloadSchema, a as ArchiveThreadResponseSchema, at as ThreadContextWindowResponseSchema, b as InterruptThreadRunResponseSchema, bt as WorkspaceFileContentResponseSchema, ct as ThreadMessageDetailResponseSchema, d as CheckoutWorkspaceBranchRequestSchema, dt as ThreadSummarySchema, et as StatusResponseSchema, ft as UpdateRuntimePreferencesRequestSchema, h as CreateThreadRequestSchema, jt as normalizePromptContext, k as PairRequestSchema, kt as createOpenApiDocument, l as ChatMessageSchema, m as ContextWindowUsageSchema, mt as VersionResponseSchema, nt as StreamThreadRunRequestSchema, ot as ThreadDetailResponseSchema, p as CommitPushWorkspaceRequestSchema, pt as UpdateWorkspaceFileContentRequestSchema, q as RunThreadRequestSchema, rt as SubmitThreadInputResponseSchema, st as ThreadMessageDetailFieldSchema, tt as StreamThreadRunEventSchema, vt as WorkspaceChangesResponseSchema, w as ListSkillsResponseSchema, y as ImageAttachmentUploadResponseSchema, z as QueuedThreadInputActionResponseSchema } from "./api-schema2.js";
2
- import { r as legacyCodexRelayDataPath, t as codexRelayDataPath } from "./paths.js";
2
+ import { a as getConnectUrlCandidates, i as createPairingQrPayload, o as getConnectUrlGuidance, r as legacyCodexRelayDataPath, s as createTursoPairingSessionStore, t as codexRelayDataPath } from "./paths.js";
3
3
  import { createRequire } from "node:module";
4
4
  import qrcode from "qrcode-terminal";
5
- import { execFile, execFileSync, spawn } from "node:child_process";
5
+ import { execFile, spawn } from "node:child_process";
6
6
  import fs, { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
7
7
  import { access, appendFile, copyFile, mkdir, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
8
8
  import path, { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { z } from "zod";
11
- import os, { homedir, hostname, networkInterfaces } from "node:os";
12
- import { serve } from "@hono/node-server";
13
11
  import { fromByteArray, toByteArray } from "base64-js";
12
+ import os, { homedir, hostname } from "node:os";
13
+ import { serve } from "@hono/node-server";
14
14
  import { createHash, randomBytes, randomUUID } from "node:crypto";
15
15
  import pc from "picocolors";
16
16
  import { openRepository } from "es-git";
@@ -24,7 +24,6 @@ import { randomBytes as randomBytes$1, utf8ToBytes } from "@noble/ciphers/utils.
24
24
  import { ed25519, x25519 } from "@noble/curves/ed25519.js";
25
25
  import { hkdf } from "@noble/hashes/hkdf.js";
26
26
  import { sha256 } from "@noble/hashes/sha2.js";
27
- import { connect } from "@tursodatabase/database";
28
27
  //#region src/app-server.ts
29
28
  var CodexAppServerClient = class {
30
29
  child;
@@ -839,6 +838,7 @@ function createApp(options = {}) {
839
838
  const appServerHistoryLoadsByThreadId = /* @__PURE__ */ new Map();
840
839
  const steeringThreads = /* @__PURE__ */ new Set();
841
840
  const secureSessionsByTokenHash = /* @__PURE__ */ new Map();
841
+ const activeStreamControllers = /* @__PURE__ */ new Set();
842
842
  const threadOptions = { workingDirectory: workspacePath };
843
843
  const scheduleAppServerHistoryLoad = (threadId, cachedMessages) => {
844
844
  if (!appServer || appServerHistoryLoadsByThreadId.has(threadId)) return;
@@ -870,7 +870,7 @@ function createApp(options = {}) {
870
870
  };
871
871
  app.use("*", cors());
872
872
  app.use("*", async (c, next) => {
873
- if (!options.pairing || c.req.method === "OPTIONS" || c.req.path === apiPaths.version || c.req.path.startsWith(`${apiPaths.imageAttachments}/`) || c.req.path.startsWith(apiPaths.pair)) {
873
+ if (!options.pairing || c.req.method === "OPTIONS" || c.req.path === apiPaths.version || c.req.path.startsWith(`${apiPaths.imageAttachments}/`) || c.req.path === apiPaths.sessionsClear || c.req.path.startsWith(apiPaths.pair)) {
874
874
  await next();
875
875
  return;
876
876
  }
@@ -978,6 +978,18 @@ function createApp(options = {}) {
978
978
  });
979
979
  return c.json({ ok: true });
980
980
  });
981
+ app.post(apiPaths.sessionsClear, async (c) => {
982
+ if (!options.pairing) return c.json(apiError("pairing_disabled", "Pairing is not enabled on this server."), 404);
983
+ if (options.pairing.approvalSecret && c.req.header("x-codex-relay-approve-secret") !== options.pairing.approvalSecret) return c.json(apiError("unauthorized", "Pairing clear must come from this machine."), 401);
984
+ const result = await options.pairing.sessions.clearAll();
985
+ secureSessionsByTokenHash.clear();
986
+ closeActiveStreamControllers(activeStreamControllers);
987
+ options.pairing.onPairingsCleared?.(result);
988
+ return c.json({
989
+ ok: true,
990
+ ...result
991
+ });
992
+ });
981
993
  app.post(apiPaths.sessionRefresh, async (c) => {
982
994
  if (!options.pairing) return c.json(apiError("pairing_disabled", "Pairing is not enabled on this server."), 404);
983
995
  const oldToken = parseBearerToken(c.req.header("authorization"));
@@ -1748,9 +1760,12 @@ function createApp(options = {}) {
1748
1760
  });
1749
1761
  const encoder = new TextEncoder();
1750
1762
  const secureSession = getSecureSessionForRequest(c, options.pairing, secureSessionsByTokenHash);
1763
+ let streamController;
1751
1764
  let streamSettled = false;
1752
1765
  const stream = new ReadableStream({
1753
1766
  start(controller) {
1767
+ streamController = controller;
1768
+ activeStreamControllers.add(controller);
1754
1769
  relayDebugLog("thread.stream.started", {
1755
1770
  mode: !runOptions.prompt && appServer ? "attach" : "run",
1756
1771
  threadId
@@ -1781,6 +1796,7 @@ function createApp(options = {}) {
1781
1796
  mode: "attach",
1782
1797
  threadId
1783
1798
  });
1799
+ activeStreamControllers.delete(controller);
1784
1800
  stopPreviewMonitor();
1785
1801
  });
1786
1802
  return;
@@ -1798,6 +1814,7 @@ function createApp(options = {}) {
1798
1814
  mode: "unsupported",
1799
1815
  threadId
1800
1816
  });
1817
+ activeStreamControllers.delete(controller);
1801
1818
  stopPreviewMonitor();
1802
1819
  return;
1803
1820
  }
@@ -1830,10 +1847,12 @@ function createApp(options = {}) {
1830
1847
  mode: "run",
1831
1848
  threadId
1832
1849
  });
1850
+ activeStreamControllers.delete(controller);
1833
1851
  stopPreviewMonitor();
1834
1852
  });
1835
1853
  },
1836
1854
  cancel(reason) {
1855
+ if (streamController) activeStreamControllers.delete(streamController);
1837
1856
  relayDebugLog("thread.stream.cancelled_by_client", {
1838
1857
  reason: debugReason(reason),
1839
1858
  settled: streamSettled,
@@ -3476,10 +3495,32 @@ function updateThread(threads, messagesByThreadId, threadId, update) {
3476
3495
  }
3477
3496
  function sendSse(controller, encoder, secureSession, event) {
3478
3497
  const parsed = StreamThreadRunEventSchema.parse(event);
3498
+ const threadId = threadIdFromStreamEvent(parsed);
3499
+ relayDebugLog("thread.stream.sse", {
3500
+ direction: "server_to_mobile",
3501
+ eventType: parsed.type,
3502
+ threadId,
3503
+ payload: parsed
3504
+ });
3479
3505
  const data = secureSession ? EncryptedPayloadSchema.parse(encryptForMobile(secureSession.session, JSON.stringify(parsed))) : parsed;
3480
3506
  if (secureSession) secureSession.persist().catch(() => void 0);
3481
- if (!enqueueSseChunk(controller, encoder.encode(`event: ${parsed.type}\n`))) return;
3482
- enqueueSseChunk(controller, encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
3507
+ if (!enqueueSseChunk(controller, encoder.encode(`event: ${parsed.type}\n`))) {
3508
+ relayDebugLog("thread.stream.sse.enqueue_failed", {
3509
+ eventType: parsed.type,
3510
+ stage: "event",
3511
+ threadId
3512
+ });
3513
+ return;
3514
+ }
3515
+ if (!enqueueSseChunk(controller, encoder.encode(`data: ${JSON.stringify(data)}\n\n`))) relayDebugLog("thread.stream.sse.enqueue_failed", {
3516
+ eventType: parsed.type,
3517
+ stage: "data",
3518
+ threadId
3519
+ });
3520
+ }
3521
+ function threadIdFromStreamEvent(event) {
3522
+ if ("threadId" in event && typeof event.threadId === "string") return event.threadId;
3523
+ if ("thread" in event && event.thread) return event.thread.id;
3483
3524
  }
3484
3525
  function enqueueSseChunk(controller, chunk) {
3485
3526
  try {
@@ -3499,6 +3540,10 @@ function closeSseController(controller) {
3499
3540
  throw error;
3500
3541
  }
3501
3542
  }
3543
+ function closeActiveStreamControllers(controllers) {
3544
+ for (const controller of controllers) closeSseController(controller);
3545
+ controllers.clear();
3546
+ }
3502
3547
  function isClosedStreamControllerError(error) {
3503
3548
  return error instanceof TypeError && error.code === "ERR_INVALID_STATE" && error.message.includes("Controller is already closed");
3504
3549
  }
@@ -5170,8 +5215,11 @@ async function git(cwd, args) {
5170
5215
  }
5171
5216
  async function listWorkspaceFiles(workspacePath, query, directory) {
5172
5217
  const normalizedQuery = query.toLowerCase();
5173
- const filePaths = await workspaceFilePaths(workspacePath);
5218
+ const isIgnored = await workspaceIgnoreMatcher(workspacePath);
5219
+ if (directory && isWorkspacePathIgnored(directory, isIgnored)) return [];
5220
+ const filePaths = await workspaceFilePaths(workspacePath, isIgnored);
5174
5221
  const entriesByPath = /* @__PURE__ */ new Map();
5222
+ for (const entry of await workspaceDirectoryEntries(workspacePath, directory, isIgnored)) entriesByPath.set(entry.path, entry);
5175
5223
  for (const path of filePaths) {
5176
5224
  if (!path || path.startsWith("../") || path.includes("/.git/")) continue;
5177
5225
  if (!directory && normalizedQuery) {
@@ -5184,6 +5232,7 @@ async function listWorkspaceFiles(workspacePath, query, directory) {
5184
5232
  const parts = path.split("/");
5185
5233
  for (let index = 1; index < parts.length; index += 1) {
5186
5234
  const directoryPath = parts.slice(0, index).join("/");
5235
+ if (isWorkspacePathIgnored(directoryPath, isIgnored)) continue;
5187
5236
  entriesByPath.set(directoryPath, {
5188
5237
  directory: dirname(directoryPath) === "." ? "" : dirname(directoryPath),
5189
5238
  kind: "directory",
@@ -5205,6 +5254,7 @@ async function listWorkspaceFiles(workspacePath, query, directory) {
5205
5254
  });
5206
5255
  else {
5207
5256
  const directoryPath = [...directory ? directory.split("/") : [], childParts[0]].join("/");
5257
+ if (isWorkspacePathIgnored(directoryPath, isIgnored)) continue;
5208
5258
  entriesByPath.set(directoryPath, {
5209
5259
  directory,
5210
5260
  kind: "directory",
@@ -5383,19 +5433,32 @@ function languageFromWorkspaceFile(path) {
5383
5433
  yml: "yaml"
5384
5434
  }[extension] ?? extension;
5385
5435
  }
5386
- async function workspaceFilePaths(workspacePath) {
5387
- const isIgnored = await workspaceIgnoreMatcher(workspacePath);
5436
+ async function workspaceFilePaths(workspacePath, isIgnored) {
5388
5437
  try {
5389
5438
  return (await git(workspacePath, [
5390
5439
  "ls-files",
5391
5440
  "--cached",
5392
5441
  "--others",
5393
5442
  "--exclude-standard"
5394
- ])).split("\n").filter(Boolean).filter((path) => !isIgnored(path));
5443
+ ])).split("\n").filter(Boolean).filter((path) => !isWorkspacePathIgnored(path, isIgnored));
5395
5444
  } catch {
5396
5445
  return recursiveWorkspaceFilePaths(workspacePath, isIgnored);
5397
5446
  }
5398
5447
  }
5448
+ async function workspaceDirectoryEntries(workspacePath, directory, isIgnored) {
5449
+ const rootPath = resolve(workspacePath);
5450
+ const absoluteDirectory = resolve(rootPath, directory);
5451
+ if (absoluteDirectory !== rootPath && !isPathInside(rootPath, absoluteDirectory)) return [];
5452
+ return (await readdir(absoluteDirectory, { withFileTypes: true })).filter((entry) => entry.isDirectory() && entry.name !== ".git").map((entry) => {
5453
+ const path = [...directory ? directory.split("/") : [], entry.name].join("/");
5454
+ return {
5455
+ directory,
5456
+ kind: "directory",
5457
+ name: entry.name,
5458
+ path
5459
+ };
5460
+ }).filter((entry) => !isWorkspacePathIgnored(entry.path, isIgnored));
5461
+ }
5399
5462
  async function recursiveWorkspaceFilePaths(rootPath, isIgnored) {
5400
5463
  const ignoredDirectories = new Set([
5401
5464
  ".git",
@@ -5411,7 +5474,7 @@ async function recursiveWorkspaceFilePaths(rootPath, isIgnored) {
5411
5474
  if (entry.name.startsWith(".") && entry.name !== ".github") continue;
5412
5475
  const absolutePath = resolve(directory, entry.name);
5413
5476
  const relativePath = relative(rootPath, absolutePath).split("\\").join("/");
5414
- if (isIgnored(relativePath)) continue;
5477
+ if (isWorkspacePathIgnored(relativePath, isIgnored)) continue;
5415
5478
  if (entry.isDirectory()) {
5416
5479
  if (ignoredDirectories.has(entry.name)) continue;
5417
5480
  await visit(absolutePath);
@@ -5427,6 +5490,17 @@ async function workspaceIgnoreMatcher(workspacePath) {
5427
5490
  const matchers = (await readFile(join(workspacePath, ".gitignore"), "utf8").catch(() => "")).split(/\r?\n/).map((line) => gitignorePatternMatcher(line)).filter((matcher) => Boolean(matcher));
5428
5491
  return (path) => matchers.some((matcher) => matcher(path.split("\\").join("/")));
5429
5492
  }
5493
+ function isWorkspacePathIgnored(path, isIgnored) {
5494
+ const normalizedPath = path.replace(/^\/+/, "").replaceAll("\\", "/").replace(/\/+$/, "");
5495
+ if (!normalizedPath) return false;
5496
+ if (isIgnored(normalizedPath) || isIgnored(`${normalizedPath}/`)) return true;
5497
+ const parts = normalizedPath.split("/");
5498
+ for (let index = 1; index < parts.length; index += 1) {
5499
+ const ancestorPath = parts.slice(0, index).join("/");
5500
+ if (isIgnored(ancestorPath) || isIgnored(`${ancestorPath}/`)) return true;
5501
+ }
5502
+ return false;
5503
+ }
5430
5504
  function gitignorePatternMatcher(line) {
5431
5505
  const trimmed = line.trim();
5432
5506
  if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("!")) return;
@@ -5439,7 +5513,7 @@ function gitignorePatternMatcher(line) {
5439
5513
  return (path) => {
5440
5514
  const normalizedPath = path.replace(/^\/+/, "");
5441
5515
  return (anchored || hasSlash ? [normalizedPath] : normalizedPath.split("/")).some((candidate) => {
5442
- if (directoryOnly) return matcher(candidate) || normalizedPath.startsWith(`${candidate}/`);
5516
+ if (directoryOnly) return matcher(candidate);
5443
5517
  return matcher(candidate);
5444
5518
  });
5445
5519
  };
@@ -5473,238 +5547,6 @@ function errorMessage(error) {
5473
5547
  return error instanceof Error ? error.message : "Codex run failed.";
5474
5548
  }
5475
5549
  //#endregion
5476
- //#region src/pairing-store.ts
5477
- async function createTursoPairingSessionStore(path) {
5478
- if (path !== ":memory:") await mkdir(dirname(path), { recursive: true });
5479
- const db = await connect(path);
5480
- await db.exec(`
5481
- CREATE TABLE IF NOT EXISTS pairing_sessions (
5482
- token_hash TEXT PRIMARY KEY,
5483
- client_session_id TEXT,
5484
- client_name TEXT,
5485
- expires_at INTEGER NOT NULL,
5486
- key_epoch INTEGER,
5487
- mobile_to_server_key TEXT,
5488
- server_to_mobile_key TEXT,
5489
- last_mobile_counter INTEGER,
5490
- next_server_counter INTEGER,
5491
- created_at INTEGER NOT NULL,
5492
- updated_at INTEGER NOT NULL
5493
- );
5494
-
5495
- CREATE TABLE IF NOT EXISTS pending_pairings (
5496
- approval_code TEXT PRIMARY KEY,
5497
- client_session_id TEXT,
5498
- client_name TEXT,
5499
- client_ephemeral_public_key TEXT NOT NULL,
5500
- client_nonce TEXT NOT NULL,
5501
- server_url TEXT NOT NULL,
5502
- approved INTEGER NOT NULL DEFAULT 0,
5503
- expires_at INTEGER NOT NULL,
5504
- created_at INTEGER NOT NULL,
5505
- updated_at INTEGER NOT NULL
5506
- );
5507
- `);
5508
- await ensurePairingSessionColumns();
5509
- async function countActive(now) {
5510
- const row = await db.prepare("SELECT COUNT(DISTINCT COALESCE(client_session_id, token_hash)) AS count FROM pairing_sessions WHERE expires_at > ?").get(now);
5511
- return Number(row?.count ?? 0);
5512
- }
5513
- async function deleteSession(tokenHash) {
5514
- await db.prepare("DELETE FROM pairing_sessions WHERE token_hash = ?").run(tokenHash);
5515
- }
5516
- async function deletePendingPairing(approvalCode) {
5517
- await db.prepare("DELETE FROM pending_pairings WHERE approval_code = ?").run(approvalCode);
5518
- }
5519
- async function getPendingPairing(approvalCode, now) {
5520
- const row = await db.prepare(`SELECT approval_code AS approvalCode,
5521
- client_session_id AS clientSessionId,
5522
- client_name AS clientName,
5523
- client_ephemeral_public_key AS clientEphemeralPublicKey,
5524
- client_nonce AS clientNonce,
5525
- server_url AS serverUrl,
5526
- approved,
5527
- expires_at AS expiresAt
5528
- FROM pending_pairings
5529
- WHERE approval_code = ?`).get(approvalCode);
5530
- if (!row) return;
5531
- const expiresAt = Number(row.expiresAt);
5532
- if (now > expiresAt) {
5533
- await deletePendingPairing(approvalCode);
5534
- return;
5535
- }
5536
- return {
5537
- approvalCode: String(row.approvalCode),
5538
- approved: Number(row.approved) === 1,
5539
- clientEphemeralPublicKey: String(row.clientEphemeralPublicKey),
5540
- clientSessionId: typeof row.clientSessionId === "string" ? row.clientSessionId : void 0,
5541
- clientName: typeof row.clientName === "string" ? row.clientName : void 0,
5542
- clientNonce: String(row.clientNonce),
5543
- expiresAt,
5544
- serverUrl: String(row.serverUrl)
5545
- };
5546
- }
5547
- return {
5548
- async approvePendingPairing(approvalCode, now) {
5549
- const pending = await getPendingPairing(approvalCode, now);
5550
- if (!pending) return;
5551
- await db.prepare("UPDATE pending_pairings SET approved = 1, updated_at = ? WHERE approval_code = ?").run(now, approvalCode);
5552
- return {
5553
- ...pending,
5554
- approved: true
5555
- };
5556
- },
5557
- countActive,
5558
- async createPendingPairing(pairing) {
5559
- const now = Date.now();
5560
- await db.prepare(`INSERT INTO pending_pairings (
5561
- approval_code,
5562
- client_session_id,
5563
- client_name,
5564
- client_ephemeral_public_key,
5565
- client_nonce,
5566
- server_url,
5567
- approved,
5568
- expires_at,
5569
- created_at,
5570
- updated_at
5571
- )
5572
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(pairing.approvalCode, pairing.clientSessionId ?? null, pairing.clientName ?? null, pairing.clientEphemeralPublicKey, pairing.clientNonce, pairing.serverUrl, pairing.approved ? 1 : 0, pairing.expiresAt, now, now);
5573
- },
5574
- async createSession(tokenHash, session) {
5575
- const now = Date.now();
5576
- const secure = encodeSecureSession(session.secureSession);
5577
- if (session.clientSessionId) {
5578
- await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id = ?").run(session.clientSessionId);
5579
- if (session.clientName) await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id IS NULL AND client_name = ?").run(session.clientName);
5580
- }
5581
- await db.prepare(`INSERT INTO pairing_sessions (
5582
- token_hash,
5583
- client_session_id,
5584
- client_name,
5585
- expires_at,
5586
- key_epoch,
5587
- mobile_to_server_key,
5588
- server_to_mobile_key,
5589
- last_mobile_counter,
5590
- next_server_counter,
5591
- created_at,
5592
- updated_at
5593
- )
5594
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(tokenHash, session.clientSessionId ?? null, session.clientName ?? null, session.expiresAt, secure?.keyEpoch ?? null, secure?.mobileToServerKey ?? null, secure?.serverToMobileKey ?? null, secure?.lastMobileCounter ?? null, secure?.nextServerCounter ?? null, now, now);
5595
- return countActive(now);
5596
- },
5597
- deleteSession,
5598
- deletePendingPairing,
5599
- getPendingPairing,
5600
- async getValidSession(tokenHash, now) {
5601
- const row = await db.prepare(`SELECT client_name AS clientName,
5602
- client_session_id AS clientSessionId,
5603
- expires_at AS expiresAt,
5604
- key_epoch AS keyEpoch,
5605
- mobile_to_server_key AS mobileToServerKey,
5606
- server_to_mobile_key AS serverToMobileKey,
5607
- last_mobile_counter AS lastMobileCounter,
5608
- next_server_counter AS nextServerCounter
5609
- FROM pairing_sessions
5610
- WHERE token_hash = ?`).get(tokenHash);
5611
- if (!row) return;
5612
- const expiresAt = Number(row.expiresAt);
5613
- if (now > expiresAt) {
5614
- await deleteSession(tokenHash);
5615
- return;
5616
- }
5617
- return {
5618
- clientSessionId: typeof row.clientSessionId === "string" ? row.clientSessionId : void 0,
5619
- clientName: typeof row.clientName === "string" ? row.clientName : void 0,
5620
- expiresAt,
5621
- secureSession: decodeSecureSession(row)
5622
- };
5623
- },
5624
- async pruneExpired(now) {
5625
- await db.prepare("DELETE FROM pairing_sessions WHERE expires_at <= ?").run(now);
5626
- await db.prepare("DELETE FROM pending_pairings WHERE expires_at <= ?").run(now);
5627
- },
5628
- async rotateSession(oldTokenHash, newTokenHash, session) {
5629
- const now = Date.now();
5630
- const secure = encodeSecureSession(session.secureSession);
5631
- await db.transaction(async () => {
5632
- await db.prepare("DELETE FROM pairing_sessions WHERE token_hash = ?").run(oldTokenHash);
5633
- if (session.clientSessionId) {
5634
- await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id = ?").run(session.clientSessionId);
5635
- if (session.clientName) await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id IS NULL AND client_name = ?").run(session.clientName);
5636
- }
5637
- await db.prepare(`INSERT INTO pairing_sessions (
5638
- token_hash,
5639
- client_session_id,
5640
- client_name,
5641
- expires_at,
5642
- key_epoch,
5643
- mobile_to_server_key,
5644
- server_to_mobile_key,
5645
- last_mobile_counter,
5646
- next_server_counter,
5647
- created_at,
5648
- updated_at
5649
- )
5650
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newTokenHash, session.clientSessionId ?? null, session.clientName ?? null, session.expiresAt, secure?.keyEpoch ?? null, secure?.mobileToServerKey ?? null, secure?.serverToMobileKey ?? null, secure?.lastMobileCounter ?? null, secure?.nextServerCounter ?? null, now, now);
5651
- })();
5652
- return countActive(now);
5653
- },
5654
- async updateSecureSession(tokenHash, secureSession) {
5655
- const secure = encodeSecureSession(secureSession);
5656
- const now = Date.now();
5657
- await db.prepare(`UPDATE pairing_sessions
5658
- SET key_epoch = ?,
5659
- mobile_to_server_key = ?,
5660
- server_to_mobile_key = ?,
5661
- last_mobile_counter = ?,
5662
- next_server_counter = ?,
5663
- updated_at = ?
5664
- WHERE token_hash = ?`).run(secure.keyEpoch, secure.mobileToServerKey, secure.serverToMobileKey, secure.lastMobileCounter, secure.nextServerCounter, now, tokenHash);
5665
- }
5666
- };
5667
- async function ensurePairingSessionColumns() {
5668
- const rows = await db.prepare("PRAGMA table_info(pairing_sessions)").all();
5669
- const columns = new Set(resultRows(rows).map((row) => String(row.name)));
5670
- for (const [column, sql] of [
5671
- ["client_session_id", "ALTER TABLE pairing_sessions ADD COLUMN client_session_id TEXT"],
5672
- ["key_epoch", "ALTER TABLE pairing_sessions ADD COLUMN key_epoch INTEGER"],
5673
- ["mobile_to_server_key", "ALTER TABLE pairing_sessions ADD COLUMN mobile_to_server_key TEXT"],
5674
- ["server_to_mobile_key", "ALTER TABLE pairing_sessions ADD COLUMN server_to_mobile_key TEXT"],
5675
- ["last_mobile_counter", "ALTER TABLE pairing_sessions ADD COLUMN last_mobile_counter INTEGER"],
5676
- ["next_server_counter", "ALTER TABLE pairing_sessions ADD COLUMN next_server_counter INTEGER"]
5677
- ]) if (!columns.has(column)) await db.exec(sql);
5678
- const pendingRows = await db.prepare("PRAGMA table_info(pending_pairings)").all();
5679
- if (!new Set(resultRows(pendingRows).map((row) => String(row.name))).has("client_session_id")) await db.exec("ALTER TABLE pending_pairings ADD COLUMN client_session_id TEXT");
5680
- }
5681
- }
5682
- function encodeSecureSession(session) {
5683
- if (!session) return;
5684
- return {
5685
- keyEpoch: session.keyEpoch,
5686
- lastMobileCounter: session.lastMobileCounter,
5687
- mobileToServerKey: fromByteArray(session.mobileToServerKey),
5688
- nextServerCounter: session.nextServerCounter,
5689
- serverToMobileKey: fromByteArray(session.serverToMobileKey)
5690
- };
5691
- }
5692
- function decodeSecureSession(row) {
5693
- if (typeof row.mobileToServerKey !== "string" || typeof row.serverToMobileKey !== "string" || row.keyEpoch === null || row.lastMobileCounter === null || row.nextServerCounter === null) return;
5694
- return {
5695
- keyEpoch: Number(row.keyEpoch),
5696
- lastMobileCounter: Number(row.lastMobileCounter),
5697
- mobileToServerKey: toByteArray(row.mobileToServerKey),
5698
- nextServerCounter: Number(row.nextServerCounter),
5699
- serverToMobileKey: toByteArray(row.serverToMobileKey)
5700
- };
5701
- }
5702
- function resultRows(result) {
5703
- if (Array.isArray(result)) return result;
5704
- if (result && typeof result === "object" && Array.isArray(result.rows)) return result.rows;
5705
- return [];
5706
- }
5707
- //#endregion
5708
5550
  //#region src/index.ts
5709
5551
  const port = Number(process.env.PORT ?? 8787);
5710
5552
  const hostname$1 = process.env.HOST ?? "0.0.0.0";
@@ -5755,6 +5597,9 @@ serve({
5755
5597
  onPairApproved: ({ clientName }) => {
5756
5598
  logRuntimeEvent("Approved", `Pairing request approved${clientName ? ` for ${clientName}` : ""}. Waiting for secure session pickup.`);
5757
5599
  },
5600
+ onPairingsCleared: ({ pendingPairingsCleared, sessionsCleared }) => {
5601
+ logRuntimeEvent("Cleared", `Signed out ${sessionsCleared} mobile session${sessionsCleared === 1 ? "" : "s"} and removed ${pendingPairingsCleared} pending pairing request${pendingPairingsCleared === 1 ? "" : "s"}.`);
5602
+ },
5758
5603
  onTokenRefreshed: ({ clientName, tokenCount }) => {
5759
5604
  logRuntimeEvent("Refreshed", `Mobile session rotated${clientName ? ` for ${clientName}` : ""}; ${formatClientCount(tokenCount)} active.`);
5760
5605
  }
@@ -5765,10 +5610,19 @@ serve({
5765
5610
  port
5766
5611
  }, (info) => {
5767
5612
  const listenUrl = `http://${info.address}:${info.port}`;
5768
- const connectUrl = getConfiguredConnectUrl() ?? getTailscaleConnectUrl(info.port) ?? getLocalNetworkConnectUrl(info.port) ?? listenUrl;
5769
- const pairingPayload = createPairingQrPayload(connectUrl);
5613
+ const connectUrlCandidates = getConnectUrlCandidates({
5614
+ listenUrl,
5615
+ port: info.port
5616
+ });
5617
+ const connectUrl = connectUrlCandidates[0]?.url ?? listenUrl;
5618
+ const connectUrls = connectUrlCandidates.map((candidate) => candidate.url);
5619
+ const pairingPayload = createPairingQrPayload({
5620
+ serverPublicKey: serverIdentity.publicKey,
5621
+ serverUrls: connectUrls.length > 0 ? connectUrls : [connectUrl]
5622
+ });
5770
5623
  writeServerState({
5771
5624
  connectUrl,
5625
+ connectUrlCandidates,
5772
5626
  host: hostname$1,
5773
5627
  listenUrl,
5774
5628
  pairingPayload,
@@ -5779,6 +5633,7 @@ serve({
5779
5633
  logRuntimeEvent("Debug", `Writing diagnostics to ${debugLogPath}`);
5780
5634
  relayDebugLog("relay.started", {
5781
5635
  connectUrl,
5636
+ connectUrlCandidates,
5782
5637
  listenUrl,
5783
5638
  port: info.port,
5784
5639
  workspacePath: process.env.CODEX_RELAY_WORKSPACE_PATH ?? process.cwd()
@@ -5788,6 +5643,7 @@ serve({
5788
5643
  qrcode.generate(pairingPayload, { small: true });
5789
5644
  console.log(formatStartupInstructions({
5790
5645
  connectUrl,
5646
+ connectUrlCandidates,
5791
5647
  dangerouslyAutoApprove,
5792
5648
  listenUrl,
5793
5649
  pairingPayload,
@@ -5801,6 +5657,8 @@ function formatStartupInstructions(details) {
5801
5657
  `${color.prompt("›")} Scan the QR code above to pair ${color.brand("Codex Relay mobile")}.`,
5802
5658
  "",
5803
5659
  `${color.prompt("›")} Mobile: ${color.url(details.connectUrl)}`,
5660
+ ...formatConnectUrlGuidance(details.connectUrl),
5661
+ ...formatConnectUrlCandidates(details.connectUrlCandidates),
5804
5662
  `${color.prompt("›")} Server: ${color.muted(details.listenUrl)}`,
5805
5663
  "",
5806
5664
  `${color.prompt("›")} Pairing: ${color.url(details.pairingPayload)}`,
@@ -5817,34 +5675,23 @@ function formatStartupInstructions(details) {
5817
5675
  ""
5818
5676
  ].join("\n");
5819
5677
  }
5678
+ function formatConnectUrlGuidance(connectUrl) {
5679
+ const guidance = getConnectUrlGuidance(connectUrl);
5680
+ return guidance ? [`${color.prompt("›")} Network: ${guidance}`] : [];
5681
+ }
5682
+ function formatConnectUrlCandidates(candidates) {
5683
+ if (candidates.length <= 1) return [];
5684
+ return [`${color.prompt("›")} QR includes ${candidates.length} candidate addresses; the app will use the first reachable one.`, ...candidates.slice(1).map((candidate) => ` ${color.muted(candidate.label)} ${color.url(candidate.url)}`)];
5685
+ }
5820
5686
  function logRuntimeEvent(label, message) {
5821
5687
  console.log(`${color.prompt("›")} ${color.event(label.padEnd(8))} ${message}`);
5822
5688
  }
5823
5689
  function formatClientCount(tokenCount) {
5824
5690
  return `${tokenCount} client${tokenCount === 1 ? "" : "s"}`;
5825
5691
  }
5826
- function createPairingQrPayload(serverUrl) {
5827
- const url = new URL("codex-relay://pair");
5828
- url.searchParams.set("serverUrl", serverUrl);
5829
- url.searchParams.set("serverPublicKey", serverIdentity.publicKey);
5830
- return url.toString();
5831
- }
5832
5692
  function hashClientToken(token) {
5833
5693
  return createHash("sha256").update(token).digest("base64url");
5834
5694
  }
5835
- function getConfiguredConnectUrl() {
5836
- const configuredUrl = normalizeUrl(process.env.CODEX_RELAY_PUBLIC_URL);
5837
- if (configuredUrl) return configuredUrl;
5838
- }
5839
- function getTailscaleConnectUrl(port) {
5840
- const status = getTailscaleStatus();
5841
- const tailscaleIp = status?.Self?.TailscaleIPs?.find((ip) => ip.startsWith("100.") && ip.includes("."));
5842
- if (tailscaleIp) return `http://${tailscaleIp}:${port}`;
5843
- const dnsName = status?.Self?.DNSName?.replace(/\.$/, "");
5844
- if (dnsName) return getTailscaleServeHttpsUrl(dnsName, port) ?? `http://${dnsName}:${port}`;
5845
- const tailscaleHost = status?.Self?.TailscaleIPs?.find((ip) => ip.includes("."));
5846
- return tailscaleHost ? `http://${tailscaleHost}:${port}` : void 0;
5847
- }
5848
5695
  async function getApprovalSecret() {
5849
5696
  if (process.env.CODEX_RELAY_APPROVAL_SECRET) return process.env.CODEX_RELAY_APPROVAL_SECRET;
5850
5697
  const path = await prepareCodexRelayDataPath("approval-secret");
@@ -5897,58 +5744,5 @@ async function writeBackgroundPid() {
5897
5744
  function formatApprovalCommand(approvalCode, activePort) {
5898
5745
  return activePort === 8787 ? `${npxCommand} approve ${approvalCode}` : `PORT=${activePort} ${npxCommand} approve ${approvalCode}`;
5899
5746
  }
5900
- function getLocalNetworkConnectUrl(port) {
5901
- for (const addresses of Object.values(networkInterfaces())) for (const address of addresses ?? []) if (address.family === "IPv4" && !address.internal) return `http://${address.address}:${port}`;
5902
- }
5903
- function normalizeUrl(value) {
5904
- if (!value) return;
5905
- const trimmed = value.trim().replace(/\/$/, "");
5906
- if (!trimmed) return;
5907
- try {
5908
- const url = new URL(trimmed);
5909
- return url.protocol === "http:" || url.protocol === "https:" ? url.toString().replace(/\/$/, "") : void 0;
5910
- } catch {
5911
- return;
5912
- }
5913
- }
5914
- function getTailscaleStatus() {
5915
- try {
5916
- const output = execFileSync("tailscale", ["status", "--json"], {
5917
- encoding: "utf8",
5918
- stdio: [
5919
- "ignore",
5920
- "pipe",
5921
- "ignore"
5922
- ],
5923
- timeout: 1500
5924
- });
5925
- return JSON.parse(output);
5926
- } catch {
5927
- return;
5928
- }
5929
- }
5930
- function getTailscaleServeHttpsUrl(dnsName, port) {
5931
- try {
5932
- const output = execFileSync("tailscale", [
5933
- "serve",
5934
- "status",
5935
- "--json"
5936
- ], {
5937
- encoding: "utf8",
5938
- stdio: [
5939
- "ignore",
5940
- "pipe",
5941
- "ignore"
5942
- ],
5943
- timeout: 1500
5944
- });
5945
- const serveStatus = JSON.parse(output);
5946
- const portKey = String(port);
5947
- const hostPort = `${dnsName}:${portKey}`;
5948
- return serveStatus.TCP?.[portKey]?.HTTPS && serveStatus.Web?.[hostPort] ? `https://${hostPort}` : void 0;
5949
- } catch {
5950
- return;
5951
- }
5952
- }
5953
5747
  //#endregion
5954
5748
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-relay",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Local Codex Relay CLI bridge for the Codex Relay mobile app.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,7 +38,7 @@
38
38
  "@noble/ciphers": "2.2.0",
39
39
  "@noble/curves": "2.2.0",
40
40
  "@noble/hashes": "2.2.0",
41
- "@openai/codex-sdk": "^0.125.0",
41
+ "@openai/codex-sdk": "^0.130.0",
42
42
  "@tursodatabase/database": "^0.5.3",
43
43
  "base64-js": "1.5.1",
44
44
  "commander": "^14.0.3",
package/src/api-schema.ts CHANGED
@@ -906,6 +906,7 @@ export const apiPaths = {
906
906
  pair: "/v1/pair",
907
907
  pairApproval: (approvalCode: string) => `/v1/pair/${encodeURIComponent(approvalCode)}`,
908
908
  pairApprove: "/v1/pair/approve",
909
+ sessionsClear: "/v1/sessions/clear",
909
910
  sessionRefresh: "/v1/session/refresh",
910
911
  status: "/v1/status",
911
912
  preferences: "/v1/preferences",