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 +3 -2
- package/dist/api-schema2.js +1 -0
- package/dist/cli.js +96 -3
- package/dist/paths.js +425 -3
- package/dist/src.js +114 -320
- package/package.json +2 -2
- package/src/api-schema.ts +1 -0
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
|
|
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
|
package/dist/api-schema2.js
CHANGED
|
@@ -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)
|
|
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 {
|
|
2
|
-
import {
|
|
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,
|
|
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`)))
|
|
3482
|
-
|
|
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
|
|
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) => !
|
|
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 (
|
|
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)
|
|
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
|
|
5769
|
-
|
|
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.
|
|
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.
|
|
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",
|