codex-relay 1.0.1-beta.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src2.js DELETED
@@ -1,3072 +0,0 @@
1
- import { C as WorkspaceGitActionResponseSchema, S as WorkspaceChangesResponseSchema, T as createOpenApiDocument, _ as StreamThreadRunEventSchema, a as CreateThreadRequestSchema, b as ThreadDetailResponseSchema, c as ListThreadsResponseSchema, d as PairResponseSchema, f as RateLimitsResponseSchema, g as StatusResponseSchema, h as RunThreadRequestSchema, i as ContextWindowUsageSchema, l as ListWorkspaceDirectoriesResponseSchema, m as ResolveApprovalResponseSchema, n as CheckoutWorkspaceBranchRequestSchema, o as EncryptedPayloadSchema, p as ResolveApprovalRequestSchema, r as CommitPushWorkspaceRequestSchema, s as ListModelsResponseSchema, t as ChatMessageSchema, u as PairRequestSchema, v as SubmitThreadInputResponseSchema, w as apiPaths, x as ThreadSummarySchema, y as ThreadContextWindowResponseSchema } from "./src.js";
2
- import qrcode from "qrcode-terminal";
3
- import { execFile, execFileSync, spawn } from "node:child_process";
4
- import fs from "node:fs";
5
- import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
6
- import path, { dirname, resolve } from "node:path";
7
- import { z } from "zod";
8
- import { serve } from "@hono/node-server";
9
- import pc from "picocolors";
10
- import { createHash, randomBytes } from "node:crypto";
11
- import os, { hostname, networkInterfaces } from "node:os";
12
- import { openRepository } from "es-git";
13
- import { Hono } from "hono";
14
- import { cors } from "hono/cors";
15
- import { promisify } from "node:util";
16
- import { Codex } from "@openai/codex-sdk";
17
- import { createInterface } from "node:readline";
18
- import { gcm } from "@noble/ciphers/aes.js";
19
- import { randomBytes as randomBytes$1, utf8ToBytes } from "@noble/ciphers/utils.js";
20
- import { ed25519, x25519 } from "@noble/curves/ed25519.js";
21
- import { hkdf } from "@noble/hashes/hkdf.js";
22
- import { sha256 } from "@noble/hashes/sha2.js";
23
- import { fromByteArray, toByteArray } from "base64-js";
24
- import { connect } from "@tursodatabase/database";
25
- //#region src/codex.ts
26
- function createCodexClient() {
27
- return new Codex();
28
- }
29
- function getThreadId(thread) {
30
- return thread.id ?? thread.threadId ?? void 0;
31
- }
32
- function stringifyRunResult(result) {
33
- if (typeof result === "string") return result;
34
- if (result && typeof result === "object") {
35
- const record = result;
36
- const knownText = record.finalResponse ?? record.final_response ?? record.outputText ?? record.output_text ?? record.text;
37
- if (typeof knownText === "string") return knownText;
38
- }
39
- return JSON.stringify(result, null, 2);
40
- }
41
- function extractStreamText(event) {
42
- if (!event || typeof event !== "object") return;
43
- const record = event;
44
- const item = record.item && typeof record.item === "object" ? record.item : void 0;
45
- const candidate = record.delta ?? record.text ?? record.message ?? item?.text ?? item?.aggregated_output ?? item?.command;
46
- return typeof candidate === "string" && candidate.length > 0 ? candidate : void 0;
47
- }
48
- function classifyStreamEvent(event) {
49
- if (!event || typeof event !== "object") return "status";
50
- const record = event;
51
- if (record.type === "error" || record.type === "turn.failed") return "error";
52
- switch ((record.item && typeof record.item === "object" ? record.item : void 0)?.type) {
53
- case "agent_message": return "assistant";
54
- case "reasoning": return "reasoning";
55
- case "command_execution":
56
- case "file_change":
57
- case "mcp_tool_call":
58
- case "web_search": return "tool";
59
- default: return "status";
60
- }
61
- }
62
- //#endregion
63
- //#region src/app-server.ts
64
- var CodexAppServerClient = class {
65
- child;
66
- initialized;
67
- notificationHandlers = /* @__PURE__ */ new Set();
68
- nextId = 1;
69
- pending = /* @__PURE__ */ new Map();
70
- requestHandlers = /* @__PURE__ */ new Set();
71
- readline;
72
- async listThreads(limit = 80) {
73
- return (await this.request("thread/list", {
74
- limit,
75
- sortKey: "updated_at",
76
- sortDirection: "desc",
77
- sourceKinds: [],
78
- archived: false
79
- })).data;
80
- }
81
- async readThread(threadId) {
82
- return (await this.request("thread/read", {
83
- threadId,
84
- includeTurns: true
85
- })).thread;
86
- }
87
- async listModels(limit = 80) {
88
- return (await this.request("model/list", {
89
- limit,
90
- includeHidden: false
91
- })).data;
92
- }
93
- async readRateLimits() {
94
- return this.request("account/rateLimits/read", null);
95
- }
96
- async startThread(params) {
97
- return (await this.request("thread/start", params)).thread;
98
- }
99
- async startTurn(params) {
100
- return (await this.request("turn/start", params)).turn;
101
- }
102
- onNotification(handler) {
103
- this.notificationHandlers.add(handler);
104
- return () => this.notificationHandlers.delete(handler);
105
- }
106
- onRequest(handler) {
107
- this.requestHandlers.add(handler);
108
- return () => this.requestHandlers.delete(handler);
109
- }
110
- async respondToRequest(id, result) {
111
- await this.writeJson({
112
- id,
113
- result
114
- });
115
- }
116
- async rejectRequest(id, code, message) {
117
- await this.writeJson({
118
- id,
119
- error: {
120
- code,
121
- message
122
- }
123
- });
124
- }
125
- close() {
126
- for (const pending of this.pending.values()) pending.reject(/* @__PURE__ */ new Error("Codex app-server was closed."));
127
- this.pending.clear();
128
- this.readline?.close();
129
- this.child?.kill();
130
- this.readline = void 0;
131
- this.child = void 0;
132
- this.initialized = void 0;
133
- }
134
- async request(method, params) {
135
- await this.ensureInitialized();
136
- const id = this.nextId++;
137
- return new Promise((resolve, reject) => {
138
- this.pending.set(id, {
139
- resolve: (value) => resolve(value),
140
- reject
141
- });
142
- this.child.stdin.write(`${JSON.stringify({
143
- id,
144
- method,
145
- params
146
- })}\n`, (error) => {
147
- if (error) {
148
- this.pending.delete(id);
149
- reject(error);
150
- }
151
- });
152
- });
153
- }
154
- ensureInitialized() {
155
- if (!this.initialized) this.initialized = this.start();
156
- return this.initialized;
157
- }
158
- start() {
159
- this.child = spawn(resolveCodexBinary(), [
160
- "app-server",
161
- "--listen",
162
- "stdio://"
163
- ], { env: process.env });
164
- this.readline = createInterface({
165
- input: this.child.stdout,
166
- crlfDelay: Infinity
167
- });
168
- this.readline.on("line", (line) => this.handleLine(line));
169
- this.child.once("error", (error) => this.rejectAll(error));
170
- this.child.once("exit", (code, signal) => {
171
- this.rejectAll(/* @__PURE__ */ new Error(`Codex app-server exited with ${signal ?? code ?? 1}.`));
172
- this.child = void 0;
173
- this.initialized = void 0;
174
- });
175
- return this.requestRaw("initialize", {
176
- clientInfo: {
177
- name: "codex-relay",
178
- title: "Codex Relay Mobile Server",
179
- version: "0.1.0"
180
- },
181
- capabilities: { experimentalApi: true }
182
- }).then(() => void 0);
183
- }
184
- requestRaw(method, params) {
185
- const id = this.nextId++;
186
- const request = JSON.stringify({
187
- id,
188
- method,
189
- params
190
- });
191
- return new Promise((resolve, reject) => {
192
- this.pending.set(id, {
193
- resolve: (value) => resolve(value),
194
- reject
195
- });
196
- this.child.stdin.write(`${request}\n`, (error) => {
197
- if (error) {
198
- this.pending.delete(id);
199
- reject(error);
200
- }
201
- });
202
- });
203
- }
204
- handleLine(line) {
205
- let message;
206
- try {
207
- message = JSON.parse(line);
208
- } catch {
209
- return;
210
- }
211
- if (typeof message.method === "string" && typeof message.id === "number") {
212
- const request = {
213
- id: message.id,
214
- method: message.method,
215
- params: message.params
216
- };
217
- if (this.requestHandlers.size === 0) this.rejectRequest(request.id, -32601, `No handler for ${request.method}.`);
218
- else for (const handler of this.requestHandlers) handler(request);
219
- return;
220
- }
221
- if (typeof message.method === "string") {
222
- const notification = {
223
- method: message.method,
224
- params: message.params
225
- };
226
- for (const handler of this.notificationHandlers) handler(notification);
227
- return;
228
- }
229
- if (typeof message.id !== "number") return;
230
- const pending = this.pending.get(message.id);
231
- if (!pending) return;
232
- this.pending.delete(message.id);
233
- if (message.error) pending.reject(new Error(message.error.message));
234
- else pending.resolve(message.result);
235
- }
236
- writeJson(payload) {
237
- return new Promise((resolve, reject) => {
238
- if (!this.child?.stdin) {
239
- reject(/* @__PURE__ */ new Error("Codex app-server is not running."));
240
- return;
241
- }
242
- this.child.stdin.write(`${JSON.stringify(payload)}\n`, (error) => {
243
- if (error) reject(error);
244
- else resolve();
245
- });
246
- });
247
- }
248
- rejectAll(error) {
249
- for (const pending of this.pending.values()) pending.reject(error);
250
- this.pending.clear();
251
- }
252
- };
253
- function resolveCodexBinary() {
254
- return process.env.CODEX_BIN ?? "codex";
255
- }
256
- //#endregion
257
- //#region src/context-window.ts
258
- const contextReadCandidateLimit = 128;
259
- const contextReadScanBytes = 512 * 1024;
260
- const threadLookupScanBytes = 512 * 1024;
261
- const turnLookupScanBytes = 16 * 1024;
262
- function readLatestContextWindowUsage({ threadId, turnId }) {
263
- const rolloutPath = findRecentRolloutFileForContextRead(resolveSessionsRoot(), {
264
- threadId,
265
- turnId
266
- });
267
- if (!rolloutPath) return {
268
- rolloutPath: null,
269
- usage: null
270
- };
271
- const stat = fs.statSync(rolloutPath);
272
- return {
273
- rolloutPath,
274
- usage: readRolloutUsageChunk({
275
- endExclusive: stat.size,
276
- filePath: rolloutPath,
277
- start: Math.max(0, stat.size - contextReadScanBytes)
278
- }).usage
279
- };
280
- }
281
- function resolveSessionsRoot() {
282
- const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
283
- return path.join(codexHome, "sessions");
284
- }
285
- function findRecentRolloutFileForContextRead(root, { threadId = "", turnId = "" }) {
286
- const candidates = collectRecentRolloutFiles(root);
287
- if (candidates.length === 0) return null;
288
- if (turnId) {
289
- for (const candidate of candidates) if (rolloutFileContainsTurnId(candidate.filePath, turnId)) return candidate.filePath;
290
- }
291
- if (threadId) {
292
- const filenameMatch = candidates.find(({ filePath }) => path.basename(filePath).includes(threadId));
293
- if (filenameMatch) return filenameMatch.filePath;
294
- for (const candidate of candidates) if (rolloutFileContainsThreadId(candidate.filePath, threadId)) return candidate.filePath;
295
- }
296
- return null;
297
- }
298
- function collectRecentRolloutFiles(root) {
299
- if (!fs.existsSync(root)) return [];
300
- const stack = [root];
301
- const candidates = [];
302
- while (stack.length > 0) {
303
- const current = stack.pop();
304
- for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
305
- const fullPath = path.join(current, entry.name);
306
- if (entry.isDirectory()) {
307
- stack.push(fullPath);
308
- continue;
309
- }
310
- if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) candidates.push({
311
- filePath: fullPath,
312
- mtimeMs: fs.statSync(fullPath).mtimeMs
313
- });
314
- }
315
- }
316
- return candidates.sort((left, right) => right.mtimeMs - left.mtimeMs).slice(0, contextReadCandidateLimit);
317
- }
318
- function rolloutFileContainsTurnId(filePath, turnId) {
319
- const stat = fs.statSync(filePath);
320
- const chunk = readFileSlice(filePath, 0, Math.min(stat.size, turnLookupScanBytes));
321
- return chunk.includes(`"turn_id":"${turnId}"`) || chunk.includes(`"turnId":"${turnId}"`);
322
- }
323
- function rolloutFileContainsThreadId(filePath, threadId) {
324
- const stat = fs.statSync(filePath);
325
- const chunk = readFileSlice(filePath, Math.max(0, stat.size - Math.min(stat.size, threadLookupScanBytes)), stat.size);
326
- return chunk.includes(`"thread_id":"${threadId}"`) || chunk.includes(`"threadId":"${threadId}"`) || chunk.includes(`"conversation_id":"${threadId}"`) || chunk.includes(`"conversationId":"${threadId}"`);
327
- }
328
- function readRolloutUsageChunk({ filePath, start, endExclusive }) {
329
- const lines = readFileSlice(filePath, start, endExclusive).split("\n");
330
- if (start > 0) lines.shift();
331
- let latestUsage = null;
332
- for (const line of lines) {
333
- const usage = extractContextUsageFromRolloutLine(line);
334
- if (usage) latestUsage = usage;
335
- }
336
- return { usage: latestUsage };
337
- }
338
- function readFileSlice(filePath, start, endExclusive) {
339
- const length = Math.max(0, endExclusive - start);
340
- if (length === 0) return "";
341
- const fileHandle = fs.openSync(filePath, "r");
342
- try {
343
- const buffer = Buffer.alloc(length);
344
- const bytesRead = fs.readSync(fileHandle, buffer, 0, length, start);
345
- return buffer.toString("utf8", 0, bytesRead);
346
- } finally {
347
- fs.closeSync(fileHandle);
348
- }
349
- }
350
- function extractContextUsageFromRolloutLine(rawLine) {
351
- let parsed;
352
- try {
353
- parsed = JSON.parse(rawLine.trim());
354
- } catch {
355
- return null;
356
- }
357
- if (!parsed || typeof parsed !== "object") return null;
358
- const record = parsed;
359
- if (record.type !== "event_msg" || !record.payload || typeof record.payload !== "object") return null;
360
- const payload = record.payload;
361
- if (payload.type !== "token_count" || !payload.info || typeof payload.info !== "object") return null;
362
- return contextUsageFromTokenCountInfo(payload.info);
363
- }
364
- function contextUsageFromTokenCountInfo(info) {
365
- const usageRoot = objectValue(info.last_token_usage ?? info.lastTokenUsage ?? info.total_token_usage ?? info.totalTokenUsage);
366
- const tokenLimit = positiveInteger(info.model_context_window ?? info.modelContextWindow ?? info.context_window ?? info.contextWindow);
367
- if (!tokenLimit) return null;
368
- const tokensUsed = positiveInteger(usageRoot?.total_tokens ?? usageRoot?.totalTokens) ?? sumPositiveIntegers([
369
- usageRoot?.input_tokens ?? usageRoot?.inputTokens,
370
- usageRoot?.output_tokens ?? usageRoot?.outputTokens,
371
- usageRoot?.reasoning_output_tokens ?? usageRoot?.reasoningOutputTokens
372
- ]);
373
- if (tokensUsed === null) return null;
374
- return ContextWindowUsageSchema.parse({
375
- tokenLimit,
376
- tokensUsed: Math.min(tokensUsed, tokenLimit)
377
- });
378
- }
379
- function objectValue(value) {
380
- return value && typeof value === "object" ? value : void 0;
381
- }
382
- function positiveInteger(value) {
383
- if (typeof value === "number" && Number.isFinite(value)) return Math.max(0, Math.trunc(value));
384
- if (typeof value === "string") {
385
- const parsed = Number.parseInt(value, 10);
386
- return Number.isFinite(parsed) ? Math.max(0, parsed) : null;
387
- }
388
- return null;
389
- }
390
- function sumPositiveIntegers(values) {
391
- let sum = 0;
392
- let found = false;
393
- for (const value of values) {
394
- const parsed = positiveInteger(value);
395
- if (parsed !== null) {
396
- sum += parsed;
397
- found = true;
398
- }
399
- }
400
- return found ? sum : null;
401
- }
402
- const handshakeTag = "codex-relay-e2ee-v1";
403
- function createServerIdentity() {
404
- const privateKey = ed25519.utils.randomSecretKey();
405
- return {
406
- privateKey,
407
- publicKey: bytesToBase64(ed25519.getPublicKey(privateKey))
408
- };
409
- }
410
- function createSecurePairing(input) {
411
- const serverEphemeralPrivateKey = x25519.utils.randomSecretKey();
412
- const serverEphemeralPublicKey = x25519.getPublicKey(serverEphemeralPrivateKey);
413
- const serverNonce = randomBytes$1(32);
414
- const transcript = pairingTranscript({
415
- clientEphemeralPublicKey: input.clientEphemeralPublicKey,
416
- clientNonce: input.clientNonce,
417
- keyEpoch: input.keyEpoch,
418
- approvalCode: input.approvalCode,
419
- serverEphemeralPublicKey: bytesToBase64(serverEphemeralPublicKey),
420
- serverIdentityPublicKey: input.serverIdentity.publicKey,
421
- serverNonce: bytesToBase64(serverNonce),
422
- serverUrl: input.serverUrl
423
- });
424
- const session = deriveSession(x25519.getSharedSecret(serverEphemeralPrivateKey, base64ToBytes(input.clientEphemeralPublicKey)), transcript, input.keyEpoch);
425
- const encryptedPayload = encryptWithKey(session.serverToMobileKey, "server", input.keyEpoch, 0, JSON.stringify({
426
- clientToken: input.clientToken,
427
- clientTokenExpiresAt: input.clientTokenExpiresAt
428
- }));
429
- session.nextServerCounter = 1;
430
- return {
431
- response: {
432
- encryptedPayload: encryptedPayload.ciphertext,
433
- keyEpoch: input.keyEpoch,
434
- protocolVersion: 1,
435
- serverEphemeralPublicKey: bytesToBase64(serverEphemeralPublicKey),
436
- serverNonce: bytesToBase64(serverNonce),
437
- serverSignature: bytesToBase64(ed25519.sign(transcript, input.serverIdentity.privateKey))
438
- },
439
- session
440
- };
441
- }
442
- function encryptForMobile(session, plaintext) {
443
- const envelope = encryptWithKey(session.serverToMobileKey, "server", session.keyEpoch, session.nextServerCounter, plaintext);
444
- session.nextServerCounter += 1;
445
- return envelope;
446
- }
447
- function decryptFromMobile(session, envelope) {
448
- if (!isEncryptedEnvelope(envelope) || envelope.sender !== "mobile") throw new Error("Expected encrypted mobile payload.");
449
- if (envelope.keyEpoch !== session.keyEpoch || envelope.counter <= session.lastMobileCounter) throw new Error("Rejected stale encrypted mobile payload.");
450
- const plaintext = decryptWithKey(session.mobileToServerKey, "mobile", envelope.counter, envelope.ciphertext);
451
- session.lastMobileCounter = envelope.counter;
452
- return plaintext;
453
- }
454
- function deriveSession(sharedSecret, transcript, keyEpoch) {
455
- const salt = sha256(transcript);
456
- const infoPrefix = `${handshakeTag}|${keyEpoch}|${bytesToBase64(sha256(transcript))}`;
457
- return {
458
- keyEpoch,
459
- lastMobileCounter: -1,
460
- mobileToServerKey: hkdf(sha256, sharedSecret, salt, utf8ToBytes(`${infoPrefix}|mobileToServer`), 32),
461
- nextServerCounter: 0,
462
- serverToMobileKey: hkdf(sha256, sharedSecret, salt, utf8ToBytes(`${infoPrefix}|serverToMobile`), 32)
463
- };
464
- }
465
- function pairingTranscript(input) {
466
- return new TextEncoder().encode(JSON.stringify({
467
- tag: handshakeTag,
468
- approvalCode: input.approvalCode,
469
- clientEphemeralPublicKey: input.clientEphemeralPublicKey,
470
- clientNonce: input.clientNonce,
471
- keyEpoch: input.keyEpoch,
472
- serverEphemeralPublicKey: input.serverEphemeralPublicKey,
473
- serverIdentityPublicKey: input.serverIdentityPublicKey,
474
- serverNonce: input.serverNonce,
475
- serverUrl: input.serverUrl
476
- }));
477
- }
478
- function encryptWithKey(key, sender, keyEpoch, counter, plaintext) {
479
- return {
480
- ciphertext: bytesToBase64(gcm(key, nonceFor(sender, counter)).encrypt(new TextEncoder().encode(plaintext))),
481
- counter,
482
- keyEpoch,
483
- protocolVersion: 1,
484
- sender
485
- };
486
- }
487
- function decryptWithKey(key, sender, counter, ciphertext) {
488
- const plaintext = gcm(key, nonceFor(sender, counter)).decrypt(base64ToBytes(ciphertext));
489
- return new TextDecoder().decode(plaintext);
490
- }
491
- function nonceFor(sender, counter) {
492
- const nonce = new Uint8Array(12);
493
- nonce[0] = sender === "mobile" ? 1 : 2;
494
- new DataView(nonce.buffer).setBigUint64(4, BigInt(counter), false);
495
- return nonce;
496
- }
497
- function isEncryptedEnvelope(value) {
498
- return value !== null && typeof value === "object" && "ciphertext" in value && "counter" in value && "keyEpoch" in value && "sender" in value && typeof value.ciphertext === "string" && typeof value.counter === "number" && typeof value.keyEpoch === "number" && (value.sender === "mobile" || value.sender === "server");
499
- }
500
- function bytesToBase64(bytes) {
501
- return fromByteArray(bytes);
502
- }
503
- function base64ToBytes(value) {
504
- return toByteArray(value);
505
- }
506
- //#endregion
507
- //#region src/app.ts
508
- const defaultWorkspacePath = process.cwd();
509
- const execFileAsync = promisify(execFile);
510
- const planModePromptPrefix = "Plan mode is active. Do not modify files or run implementation commands. Analyze the request, ask concise clarifying questions only if needed, and produce a concrete plan. Stop after the plan unless the user asks to execute.";
511
- const defaultWebPreviewPorts = [
512
- 3e3,
513
- 3001,
514
- 5173,
515
- 4173,
516
- 8080,
517
- 19006
518
- ];
519
- const PairApproveRequestSchema = z.object({ approvalCode: z.string().trim().min(1) });
520
- function createApp(options = {}) {
521
- const app = new Hono();
522
- const appServer = options.appServer === void 0 ? process.env.VITEST ? null : new CodexAppServerClient() : options.appServer;
523
- const codex = options.codex ?? createCodexClient();
524
- const workspacePath = resolve(options.workspacePath ?? process.env.CODEX_RELAY_WORKSPACE_PATH ?? defaultWorkspacePath);
525
- const threads = /* @__PURE__ */ new Map();
526
- const messagesByThreadId = /* @__PURE__ */ new Map();
527
- const liveThreads = /* @__PURE__ */ new Map();
528
- const pendingApprovals = /* @__PURE__ */ new Map();
529
- const queuedInputsByThreadId = /* @__PURE__ */ new Map();
530
- const steeringThreads = /* @__PURE__ */ new Set();
531
- const secureSessionsByTokenHash = /* @__PURE__ */ new Map();
532
- const threadOptions = { workingDirectory: workspacePath };
533
- app.use("*", cors());
534
- app.use("*", async (c, next) => {
535
- if (!options.pairing || c.req.method === "OPTIONS" || c.req.path.startsWith(apiPaths.pair)) {
536
- await next();
537
- return;
538
- }
539
- const token = parseBearerToken(c.req.header("authorization"));
540
- const tokenHash = token ? options.pairing.hashClientToken(token) : void 0;
541
- const validSession = tokenHash ? await options.pairing.sessions.getValidSession(tokenHash, Date.now()) : void 0;
542
- if (!tokenHash || !validSession) return c.json(apiError("unauthorized", "Pair this device with the Codex Relay server."), 401);
543
- if (options.pairing.serverIdentity && !secureSessionsByTokenHash.has(tokenHash)) if (validSession.secureSession) secureSessionsByTokenHash.set(tokenHash, validSession.secureSession);
544
- else return c.json(apiError("secure_session_required", "Secure session expired. Pair this device again."), 401);
545
- await next();
546
- });
547
- app.post(apiPaths.pair, async (c) => {
548
- if (!options.pairing) return c.json(apiError("pairing_disabled", "Pairing is not enabled on this server."), 404);
549
- options.pairing.onPairAttempt?.({ remoteAddress: c.req.header("x-forwarded-for") ?? c.req.header("x-real-ip") ?? c.req.header("cf-connecting-ip") });
550
- const parsed = await parsePlainJson(c.req.raw, PairRequestSchema);
551
- if (!parsed.success) return c.json(validationError(parsed.error), 400);
552
- if (!parsed.data.secure || !options.pairing.serverIdentity) return c.json(apiError("secure_pairing_required", "Pairing requires the secure QR approval flow."), 400);
553
- await options.pairing.sessions.pruneExpired(Date.now());
554
- const approvalCode = await createApprovalCode(options.pairing.sessions);
555
- const expiresAt = Date.now() + (options.pairing.approvalTtlMs ?? 300 * 1e3);
556
- await options.pairing.sessions.createPendingPairing({
557
- approved: false,
558
- approvalCode,
559
- clientEphemeralPublicKey: parsed.data.secure.clientEphemeralPublicKey,
560
- clientSessionId: parsed.data.clientSessionId,
561
- clientName: parsed.data.clientName,
562
- clientNonce: parsed.data.secure.clientNonce,
563
- expiresAt,
564
- serverUrl: new URL(c.req.url).origin
565
- });
566
- options.pairing.onPairApprovalRequested?.({
567
- approvalCode,
568
- clientName: parsed.data.clientName
569
- });
570
- const response = PairResponseSchema.parse({
571
- approvalCode,
572
- approvalExpiresAt: new Date(expiresAt).toISOString()
573
- });
574
- return c.json(response, 202);
575
- });
576
- app.get("/v1/pair/:approvalCode", async (c) => {
577
- if (!options.pairing?.serverIdentity) return c.json(apiError("pairing_disabled", "Pairing is not enabled on this server."), 404);
578
- const approvalCode = normalizeApprovalCode(c.req.param("approvalCode"));
579
- const pending = await options.pairing.sessions.getPendingPairing(approvalCode, Date.now());
580
- if (!pending) return c.json(apiError("pairing_expired", "The pairing approval code has expired."), 410);
581
- if (!pending.approved) return c.json(PairResponseSchema.parse({
582
- approvalCode,
583
- approvalExpiresAt: new Date(pending.expiresAt).toISOString()
584
- }), 202);
585
- await options.pairing.sessions.pruneExpired(Date.now());
586
- const clientToken = options.pairing.createClientToken();
587
- const expiresAt = Date.now() + options.pairing.tokenTtlMs;
588
- const tokenHash = options.pairing.hashClientToken(clientToken);
589
- const clientTokenExpiresAt = new Date(expiresAt).toISOString();
590
- const pairing = createSecurePairing({
591
- approvalCode,
592
- clientEphemeralPublicKey: pending.clientEphemeralPublicKey,
593
- clientNonce: pending.clientNonce,
594
- clientToken,
595
- clientTokenExpiresAt,
596
- keyEpoch: 1,
597
- serverIdentity: options.pairing.serverIdentity,
598
- serverUrl: pending.serverUrl
599
- });
600
- const tokenCount = await options.pairing.sessions.createSession(tokenHash, {
601
- clientSessionId: pending.clientSessionId,
602
- clientName: pending.clientName,
603
- expiresAt,
604
- secureSession: pairing.session
605
- });
606
- await options.pairing.sessions.deletePendingPairing(approvalCode);
607
- options.pairing.onPaired?.({
608
- clientName: pending.clientName,
609
- tokenCount
610
- });
611
- secureSessionsByTokenHash.set(tokenHash, pairing.session);
612
- return c.json(PairResponseSchema.parse({ secure: pairing.response }), 201);
613
- });
614
- app.post(apiPaths.pairApprove, async (c) => {
615
- if (!options.pairing) return c.json(apiError("pairing_disabled", "Pairing is not enabled on this server."), 404);
616
- if (options.pairing.approvalSecret && c.req.header("x-codex-relay-approve-secret") !== options.pairing.approvalSecret) return c.json(apiError("unauthorized", "Pairing approval must come from this machine."), 401);
617
- const parsed = await parsePlainJson(c.req.raw, PairApproveRequestSchema);
618
- if (!parsed.success) return c.json(validationError(parsed.error), 400);
619
- const approvalCode = normalizeApprovalCode(parsed.data.approvalCode);
620
- const pending = await options.pairing.sessions.approvePendingPairing(approvalCode, Date.now());
621
- if (!pending) return c.json(apiError("not_found", "No pending pairing request matches that code."), 404);
622
- options.pairing.onPairApproved?.({
623
- approvalCode,
624
- clientName: pending.clientName
625
- });
626
- return c.json({ ok: true });
627
- });
628
- app.post(apiPaths.sessionRefresh, async (c) => {
629
- if (!options.pairing) return c.json(apiError("pairing_disabled", "Pairing is not enabled on this server."), 404);
630
- const oldToken = parseBearerToken(c.req.header("authorization"));
631
- const oldSession = oldToken ? await getValidClientSession(options.pairing, oldToken) : void 0;
632
- if (!oldToken || !oldSession) return c.json(apiError("unauthorized", "Pair this device with the Codex Relay server."), 401);
633
- const clientToken = options.pairing.createClientToken();
634
- const expiresAt = Date.now() + options.pairing.tokenTtlMs;
635
- const oldTokenHash = options.pairing.hashClientToken(oldToken);
636
- const newTokenHash = options.pairing.hashClientToken(clientToken);
637
- const clientSessionId = normalizeClientSessionId(c.req.header("x-codex-relay-client-session-id")) ?? oldSession.clientSessionId;
638
- const tokenCount = await options.pairing.sessions.rotateSession(oldTokenHash, newTokenHash, {
639
- clientSessionId,
640
- clientName: oldSession.clientName,
641
- expiresAt,
642
- secureSession: secureSessionsByTokenHash.get(oldTokenHash) ?? oldSession.secureSession
643
- });
644
- const secureSession = secureSessionsByTokenHash.get(oldTokenHash) ?? oldSession.secureSession;
645
- if (secureSession) secureSessionsByTokenHash.set(oldTokenHash, secureSession);
646
- options.pairing.onTokenRefreshed?.({
647
- clientName: oldSession.clientName,
648
- tokenCount
649
- });
650
- const response = PairResponseSchema.parse({
651
- clientToken,
652
- clientTokenExpiresAt: new Date(expiresAt).toISOString()
653
- });
654
- const jsonResponse = await secureJson(c, options.pairing, secureSessionsByTokenHash, response, 201);
655
- if (secureSession) {
656
- secureSessionsByTokenHash.delete(oldTokenHash);
657
- secureSessionsByTokenHash.set(newTokenHash, secureSession);
658
- await options.pairing.sessions.updateSecureSession(newTokenHash, secureSession);
659
- }
660
- return jsonResponse;
661
- });
662
- app.get(apiPaths.status, (c) => {
663
- const response = StatusResponseSchema.parse({
664
- ok: true,
665
- service: "codex-relay-server",
666
- sdkAvailable: Boolean(codex),
667
- machineName: hostname(),
668
- workspacePath,
669
- threadCount: threads.size,
670
- appServerAvailable: Boolean(appServer)
671
- });
672
- return secureJson(c, options.pairing, secureSessionsByTokenHash, response);
673
- });
674
- app.get(apiPaths.workspaceDirectories, async (c) => {
675
- const targetPath = resolve(c.req.query("path") ?? workspacePath);
676
- try {
677
- if (!(await stat(targetPath)).isDirectory()) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("invalid_workspace_path", "Workspace path must be a directory."), 400);
678
- const directories = (await readdir(targetPath, { withFileTypes: true })).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => ({
679
- name: entry.name,
680
- path: resolve(targetPath, entry.name)
681
- })).sort((left, right) => left.name.localeCompare(right.name));
682
- const response = ListWorkspaceDirectoriesResponseSchema.parse({
683
- rootPath: workspacePath,
684
- path: targetPath,
685
- parentPath: dirname(targetPath) === targetPath ? null : dirname(targetPath),
686
- directories
687
- });
688
- return secureJson(c, options.pairing, secureSessionsByTokenHash, response);
689
- } catch (error) {
690
- return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("workspace_unavailable", errorMessage(error)), 400);
691
- }
692
- });
693
- app.get(apiPaths.workspaceChanges, async (c) => {
694
- try {
695
- const selectedWorkspacePath = await validateThreadWorkspacePath(workspacePath, c.req.query("workspacePath"));
696
- if (!selectedWorkspacePath.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("invalid_workspace_path", selectedWorkspacePath.error), 400);
697
- const changes = await readWorkspaceChanges(selectedWorkspacePath.path);
698
- const response = WorkspaceChangesResponseSchema.parse({
699
- workspacePath: selectedWorkspacePath.path,
700
- ...changes
701
- });
702
- return secureJson(c, options.pairing, secureSessionsByTokenHash, response);
703
- } catch (error) {
704
- return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("workspace_changes_unavailable", errorMessage(error)), 400);
705
- }
706
- });
707
- app.post(apiPaths.workspaceCheckout, async (c) => {
708
- const parsed = await parseRequestJson(c, options.pairing, secureSessionsByTokenHash, CheckoutWorkspaceBranchRequestSchema);
709
- if (!parsed.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, validationError(parsed.error), 400);
710
- try {
711
- const selectedWorkspacePath = await validateThreadWorkspacePath(workspacePath, parsed.data.workspacePath);
712
- if (!selectedWorkspacePath.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("invalid_workspace_path", selectedWorkspacePath.error), 400);
713
- const branchExists = await localGitBranchExists(selectedWorkspacePath.path, parsed.data.branch);
714
- const output = branchExists ? await git(selectedWorkspacePath.path, ["checkout", parsed.data.branch]) : await git(selectedWorkspacePath.path, [
715
- "checkout",
716
- "-b",
717
- parsed.data.branch
718
- ]);
719
- const response = WorkspaceGitActionResponseSchema.parse({
720
- branch: await currentGitBranch(selectedWorkspacePath.path),
721
- message: branchExists ? `Checked out ${parsed.data.branch}.` : `Created and checked out ${parsed.data.branch}.`,
722
- output
723
- });
724
- return secureJson(c, options.pairing, secureSessionsByTokenHash, response);
725
- } catch (error) {
726
- return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("workspace_checkout_failed", errorMessage(error)), 400);
727
- }
728
- });
729
- app.post(apiPaths.workspaceCommitPush, async (c) => {
730
- const parsed = await parseRequestJson(c, options.pairing, secureSessionsByTokenHash, CommitPushWorkspaceRequestSchema);
731
- if (!parsed.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, validationError(parsed.error), 400);
732
- try {
733
- const selectedWorkspacePath = await validateThreadWorkspacePath(workspacePath, parsed.data.workspacePath);
734
- if (!selectedWorkspacePath.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("invalid_workspace_path", selectedWorkspacePath.error), 400);
735
- await git(selectedWorkspacePath.path, ["add", "--all"]);
736
- const commitOutput = await git(selectedWorkspacePath.path, [
737
- "commit",
738
- "-m",
739
- parsed.data.message
740
- ]);
741
- const branch = await currentGitBranch(selectedWorkspacePath.path);
742
- const pushOutput = await git(selectedWorkspacePath.path, [
743
- "rev-parse",
744
- "--abbrev-ref",
745
- "@{upstream}"
746
- ]).catch(() => null) ? await git(selectedWorkspacePath.path, ["push"]) : branch ? await git(selectedWorkspacePath.path, [
747
- "push",
748
- "-u",
749
- "origin",
750
- branch
751
- ]) : await git(selectedWorkspacePath.path, ["push"]);
752
- const response = WorkspaceGitActionResponseSchema.parse({
753
- branch,
754
- message: "Committed and pushed workspace changes.",
755
- output: [commitOutput, pushOutput].filter(Boolean).join("\n")
756
- });
757
- return secureJson(c, options.pairing, secureSessionsByTokenHash, response);
758
- } catch (error) {
759
- return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("workspace_commit_push_failed", errorMessage(error)), 400);
760
- }
761
- });
762
- app.get(apiPaths.models, async (c) => {
763
- try {
764
- const models = appServer ? await appServer.listModels() : fallbackModels();
765
- return secureJson(c, options.pairing, secureSessionsByTokenHash, ListModelsResponseSchema.parse({ models: models.map(mapAppServerModel) }));
766
- } catch {
767
- return secureJson(c, options.pairing, secureSessionsByTokenHash, ListModelsResponseSchema.parse({ models: fallbackModels().map(mapAppServerModel) }));
768
- }
769
- });
770
- app.get(apiPaths.rateLimits, async (c) => {
771
- if (!appServer) return secureJson(c, options.pairing, secureSessionsByTokenHash, RateLimitsResponseSchema.parse({ buckets: [] }));
772
- try {
773
- const rateLimits = await appServer.readRateLimits();
774
- return secureJson(c, options.pairing, secureSessionsByTokenHash, RateLimitsResponseSchema.parse({ buckets: normalizeRateLimitBuckets(rateLimits) }));
775
- } catch (error) {
776
- return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("rate_limits_unavailable", errorMessage(error)), 502);
777
- }
778
- });
779
- app.get(apiPaths.threads, async (c) => {
780
- if (appServer) try {
781
- const appServerThreads = await appServer.listThreads();
782
- const response = ListThreadsResponseSchema.parse({
783
- threads: appServerThreads.map(mapAppServerThread),
784
- source: "app-server"
785
- });
786
- return secureJson(c, options.pairing, secureSessionsByTokenHash, response);
787
- } catch {}
788
- const response = ListThreadsResponseSchema.parse({
789
- threads: sortedThreads(threads),
790
- source: "memory"
791
- });
792
- return secureJson(c, options.pairing, secureSessionsByTokenHash, response);
793
- });
794
- app.get("/v1/threads/:threadId", async (c) => {
795
- const threadId = c.req.param("threadId");
796
- if (appServer) try {
797
- const thread = await appServer.readThread(threadId);
798
- return secureJson(c, options.pairing, secureSessionsByTokenHash, ThreadDetailResponseSchema.parse({
799
- thread: mapAppServerThread(thread),
800
- messages: mapAppServerMessages(thread)
801
- }));
802
- } catch {}
803
- const thread = threads.get(threadId);
804
- if (!thread) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("not_found", `Thread ${threadId} is not known to this server.`), 404);
805
- return secureJson(c, options.pairing, secureSessionsByTokenHash, ThreadDetailResponseSchema.parse({
806
- thread,
807
- messages: messagesByThreadId.get(threadId) ?? []
808
- }));
809
- });
810
- app.get("/v1/threads/:threadId/context-window", async (c) => {
811
- const threadId = c.req.param("threadId");
812
- const result = readLatestContextWindowUsage({ threadId });
813
- return secureJson(c, options.pairing, secureSessionsByTokenHash, ThreadContextWindowResponseSchema.parse({
814
- rolloutPath: result.rolloutPath,
815
- threadId,
816
- usage: result.usage
817
- }));
818
- });
819
- app.get("/openapi.json", (c) => secureJson(c, options.pairing, secureSessionsByTokenHash, createOpenApiDocument()));
820
- app.post("/v1/approvals/:approvalId", async (c) => {
821
- const approvalId = c.req.param("approvalId");
822
- const pending = pendingApprovals.get(approvalId);
823
- if (!pending) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("not_found", "This approval request is no longer pending."), 404);
824
- const parsed = await parseRequestJson(c, options.pairing, secureSessionsByTokenHash, ResolveApprovalRequestSchema);
825
- if (!parsed.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, validationError(parsed.error), 400);
826
- pendingApprovals.delete(approvalId);
827
- await resolveAppServerRequest(pending, parsed.data.decision, parsed.data.answers ?? []);
828
- return secureJson(c, options.pairing, secureSessionsByTokenHash, ResolveApprovalResponseSchema.parse({ ok: true }));
829
- });
830
- app.post(apiPaths.threads, async (c) => {
831
- const parsed = await parseRequestJson(c, options.pairing, secureSessionsByTokenHash, CreateThreadRequestSchema);
832
- if (!parsed.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, validationError(parsed.error), 400);
833
- const selectedWorkspacePath = await validateThreadWorkspacePath(workspacePath, parsed.data.workspacePath);
834
- if (!selectedWorkspacePath.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("invalid_workspace_path", selectedWorkspacePath.error), 400);
835
- const { threadId } = appServer ? await createAppServerThreadRecord({
836
- appServer,
837
- messagesByThreadId,
838
- options: parsed.data,
839
- threads,
840
- title: parsed.data.title,
841
- workspacePath: selectedWorkspacePath.path
842
- }) : createThreadRecord({
843
- codex,
844
- liveThreads,
845
- messagesByThreadId,
846
- threads,
847
- title: parsed.data.title,
848
- prompt: parsed.data.prompt,
849
- threadOptions: buildThreadOptions({
850
- ...threadOptions,
851
- workingDirectory: selectedWorkspacePath.path
852
- }, parsed.data)
853
- });
854
- if (!parsed.data.prompt) {
855
- const response = {
856
- thread: threads.get(threadId),
857
- messages: messagesByThreadId.get(threadId) ?? []
858
- };
859
- return secureJson(c, options.pairing, secureSessionsByTokenHash, response, 201);
860
- }
861
- const response = await runPromptBuffered({
862
- codex,
863
- liveThreads,
864
- messagesByThreadId,
865
- prompt: parsed.data.prompt,
866
- attachments: parsed.data.attachments ?? [],
867
- threadId,
868
- threadOptions: {
869
- ...threadOptions,
870
- workingDirectory: selectedWorkspacePath.path
871
- },
872
- runOptions: parsed.data,
873
- threads
874
- });
875
- return secureJson(c, options.pairing, secureSessionsByTokenHash, response.body, response.status);
876
- });
877
- app.post("/v1/threads/:threadId/runs", async (c) => {
878
- const threadId = c.req.param("threadId");
879
- const knownThread = await ensureKnownThread({
880
- appServer,
881
- threadId,
882
- messagesByThreadId,
883
- threads
884
- });
885
- if (!knownThread) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("not_found", `Thread ${threadId} is not known to this server.`), 404);
886
- const parsed = await parseRequestJson(c, options.pairing, secureSessionsByTokenHash, RunThreadRequestSchema);
887
- if (!parsed.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, validationError(parsed.error), 400);
888
- const response = await runPromptBuffered({
889
- codex,
890
- liveThreads,
891
- messagesByThreadId,
892
- prompt: parsed.data.prompt,
893
- attachments: parsed.data.attachments ?? [],
894
- threadId,
895
- threadOptions: {
896
- ...threadOptions,
897
- workingDirectory: knownThread.cwd ?? workspacePath
898
- },
899
- runOptions: parsed.data,
900
- threads
901
- });
902
- return secureJson(c, options.pairing, secureSessionsByTokenHash, response.body, response.status);
903
- });
904
- app.post("/v1/threads/:threadId/input", async (c) => {
905
- const threadId = c.req.param("threadId");
906
- const knownThread = await ensureKnownThread({
907
- appServer,
908
- threadId,
909
- messagesByThreadId,
910
- threads
911
- });
912
- if (!knownThread) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("not_found", `Thread ${threadId} is not known to this server.`), 404);
913
- if (!appServer) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("unsupported", "Running-thread input requires the Codex app-server."), 409);
914
- if (knownThread.state !== "running") return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("thread_not_running", `Thread ${threadId} is not currently running.`), 409);
915
- const parsed = await parseRequestJson(c, options.pairing, secureSessionsByTokenHash, RunThreadRequestSchema);
916
- if (!parsed.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, validationError(parsed.error), 400);
917
- const queuedInputs = queuedInputsByThreadId.get(threadId) ?? [];
918
- const queuedInput = {
919
- attachments: parsed.data.attachments ?? [],
920
- prompt: parsed.data.prompt,
921
- runOptions: parsed.data,
922
- workspacePath: knownThread.cwd ?? workspacePath
923
- };
924
- const shouldQueue = steeringThreads.has(threadId) || queuedInputs.length > 0;
925
- if (shouldQueue) {
926
- queuedInputs.push(queuedInput);
927
- queuedInputsByThreadId.set(threadId, queuedInputs);
928
- } else {
929
- steeringThreads.add(threadId);
930
- await startAppServerTurn(appServer, threadId, queuedInput);
931
- }
932
- const thread = updateThread(threads, messagesByThreadId, threadId, {
933
- state: "running",
934
- lastPrompt: promptWithAttachments(parsed.data.prompt, parsed.data.attachments ?? []),
935
- lastError: void 0
936
- });
937
- const response = SubmitThreadInputResponseSchema.parse({
938
- acceptedAs: shouldQueue ? "queued" : "steering",
939
- queueLength: queuedInputsByThreadId.get(threadId)?.length ?? 0,
940
- thread
941
- });
942
- return secureJson(c, options.pairing, secureSessionsByTokenHash, response, 202);
943
- });
944
- app.post("/v1/threads/:threadId/runs/stream", async (c) => {
945
- const threadId = c.req.param("threadId");
946
- const knownThread = await ensureKnownThread({
947
- appServer,
948
- threadId,
949
- messagesByThreadId,
950
- threads
951
- });
952
- if (!knownThread) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("not_found", `Thread ${threadId} is not known to this server.`), 404);
953
- const parsed = await parseRequestJson(c, options.pairing, secureSessionsByTokenHash, RunThreadRequestSchema);
954
- if (!parsed.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, validationError(parsed.error), 400);
955
- const encoder = new TextEncoder();
956
- const secureSession = getSecureSessionForRequest(c, options.pairing, secureSessionsByTokenHash);
957
- const stream = new ReadableStream({ start(controller) {
958
- const stopPreviewMonitor = startWebPreviewTargetMonitor({
959
- bridgeUrl: c.req.url,
960
- send(target) {
961
- sendSse(controller, encoder, secureSession, {
962
- type: "thread.preview_target.detected",
963
- threadId,
964
- target
965
- });
966
- }
967
- });
968
- runPromptStreamed({
969
- appServer,
970
- controller,
971
- codex,
972
- encoder,
973
- liveThreads,
974
- messagesByThreadId,
975
- pendingApprovals,
976
- queuedInputsByThreadId,
977
- prompt: parsed.data.prompt,
978
- attachments: parsed.data.attachments ?? [],
979
- secureSession,
980
- steeringThreads,
981
- threadId,
982
- threadOptions: {
983
- ...threadOptions,
984
- workingDirectory: knownThread.cwd ?? workspacePath
985
- },
986
- runOptions: parsed.data,
987
- threads
988
- }).finally(stopPreviewMonitor);
989
- } });
990
- return new Response(stream, { headers: {
991
- "cache-control": "no-cache",
992
- connection: "keep-alive",
993
- "content-type": "text/event-stream; charset=utf-8"
994
- } });
995
- });
996
- return app;
997
- }
998
- function parseBearerToken(value) {
999
- return (value?.match(/^Bearer\s+(.+)$/i))?.[1];
1000
- }
1001
- function normalizeApprovalCode(value) {
1002
- const normalized = value.toUpperCase().replace(/[^A-Z0-9]/g, "").replaceAll("O", "0").replaceAll("I", "1");
1003
- return normalized.length === 8 ? `${normalized.slice(0, 4)}-${normalized.slice(4)}` : normalized;
1004
- }
1005
- function normalizeClientSessionId(value) {
1006
- const trimmed = value?.trim();
1007
- return trimmed ? trimmed.slice(0, 120) : void 0;
1008
- }
1009
- async function validateThreadWorkspacePath(rootPath, requestedPath) {
1010
- const resolved = resolve(requestedPath ?? rootPath);
1011
- try {
1012
- if (!(await stat(resolved)).isDirectory()) return {
1013
- success: false,
1014
- error: "New chat workspace must be a directory."
1015
- };
1016
- } catch (error) {
1017
- return {
1018
- success: false,
1019
- error: errorMessage(error)
1020
- };
1021
- }
1022
- return {
1023
- success: true,
1024
- path: resolved
1025
- };
1026
- }
1027
- async function createApprovalCode(sessions) {
1028
- for (let attempt = 0; attempt < 8; attempt += 1) {
1029
- const code = normalizeApprovalCode(crypto.randomUUID().replace(/-/g, "").slice(0, 8));
1030
- if (!await sessions.getPendingPairing(code, Date.now())) return code;
1031
- }
1032
- throw new Error("Unable to allocate a pairing approval code.");
1033
- }
1034
- async function getValidClientSession(pairing, token) {
1035
- return pairing.sessions.getValidSession(pairing.hashClientToken(token), Date.now());
1036
- }
1037
- function createThreadRecord(input) {
1038
- const now = (/* @__PURE__ */ new Date()).toISOString();
1039
- const thread = input.codex.startThread(input.threadOptions);
1040
- const threadId = getThreadId(thread) ?? `local-${crypto.randomUUID()}`;
1041
- const metadata = ThreadSummarySchema.parse({
1042
- id: threadId,
1043
- title: input.title ?? titleFromPrompt(input.prompt) ?? "New Codex thread",
1044
- createdAt: now,
1045
- updatedAt: now,
1046
- state: "idle",
1047
- cwd: input.threadOptions?.workingDirectory,
1048
- messageCount: 0
1049
- });
1050
- input.threads.set(threadId, metadata);
1051
- input.messagesByThreadId.set(threadId, []);
1052
- input.liveThreads.set(threadId, thread);
1053
- return { threadId };
1054
- }
1055
- async function createAppServerThreadRecord(input) {
1056
- const runtime = resolveAppServerRuntime(input.options.runtimeMode, input.workspacePath);
1057
- const thread = await input.appServer.startThread({
1058
- approvalPolicy: runtime.approvalPolicy,
1059
- cwd: input.workspacePath,
1060
- experimentalRawEvents: false,
1061
- model: input.options.model ?? null,
1062
- persistExtendedHistory: true,
1063
- sandbox: runtime.sandbox
1064
- });
1065
- const metadata = mapAppServerThread({
1066
- ...thread,
1067
- name: input.title ?? thread.name,
1068
- preview: input.title ?? thread.preview
1069
- });
1070
- input.threads.set(thread.id, metadata);
1071
- input.messagesByThreadId.set(thread.id, []);
1072
- return { threadId: thread.id };
1073
- }
1074
- async function runPromptBuffered(input) {
1075
- const displayPrompt = promptWithAttachments(input.prompt, input.attachments);
1076
- const runPrompt = promptWithAttachments(promptForCollaborationMode(input.prompt, input.runOptions.collaborationMode), input.attachments);
1077
- const userMessage = appendMessage(input.messagesByThreadId, input.threadId, {
1078
- role: "user",
1079
- content: displayPrompt,
1080
- details: attachmentDetails(input.attachments)
1081
- });
1082
- updateThread(input.threads, input.messagesByThreadId, input.threadId, {
1083
- state: "running",
1084
- lastPrompt: displayPrompt,
1085
- lastError: void 0,
1086
- title: maybeReplaceDefaultTitle(input.threads.get(input.threadId)?.title, displayPrompt)
1087
- });
1088
- try {
1089
- const options = buildThreadOptions(input.threadOptions, input.runOptions);
1090
- const thread = hasExplicitRunOptions(input.runOptions) ? input.codex.resumeThread(input.threadId, options) : input.liveThreads.get(input.threadId) ?? input.codex.resumeThread(input.threadId, options);
1091
- input.liveThreads.set(input.threadId, thread);
1092
- const result = stringifyRunResult(await thread.run(runPrompt));
1093
- const actualThreadId = replaceLocalThreadId(input.threads, input.messagesByThreadId, input.liveThreads, input.threadId, getThreadId(thread));
1094
- const assistantMessage = appendMessage(input.messagesByThreadId, actualThreadId, {
1095
- role: "assistant",
1096
- content: result,
1097
- state: "completed"
1098
- });
1099
- return {
1100
- status: 200,
1101
- body: {
1102
- thread: updateThread(input.threads, input.messagesByThreadId, actualThreadId, {
1103
- state: "completed",
1104
- lastResult: result,
1105
- lastError: void 0
1106
- }),
1107
- messages: [userMessage, assistantMessage],
1108
- result
1109
- }
1110
- };
1111
- } catch (error) {
1112
- const failed = updateThread(input.threads, input.messagesByThreadId, input.threadId, {
1113
- state: "failed",
1114
- lastError: errorMessage(error)
1115
- });
1116
- appendMessage(input.messagesByThreadId, input.threadId, {
1117
- role: "error",
1118
- content: failed.lastError ?? "Codex run failed.",
1119
- state: "failed"
1120
- });
1121
- return {
1122
- status: 500,
1123
- body: apiError("codex_run_failed", failed.lastError ?? "Codex run failed.")
1124
- };
1125
- }
1126
- }
1127
- async function runPromptStreamed(input) {
1128
- if (input.appServer) {
1129
- await runAppServerPromptStreamed({
1130
- appServer: input.appServer,
1131
- attachments: input.attachments,
1132
- controller: input.controller,
1133
- encoder: input.encoder,
1134
- messagesByThreadId: input.messagesByThreadId,
1135
- pendingApprovals: input.pendingApprovals,
1136
- queuedInputsByThreadId: input.queuedInputsByThreadId,
1137
- prompt: input.prompt,
1138
- runOptions: input.runOptions,
1139
- secureSession: input.secureSession,
1140
- steeringThreads: input.steeringThreads,
1141
- threadId: input.threadId,
1142
- threads: input.threads,
1143
- workspacePath: input.threadOptions?.workingDirectory ?? defaultWorkspacePath
1144
- });
1145
- return;
1146
- }
1147
- let activeThreadId = input.threadId;
1148
- let assistantMessage;
1149
- try {
1150
- const displayPrompt = promptWithAttachments(input.prompt, input.attachments);
1151
- const runPrompt = promptWithAttachments(promptForCollaborationMode(input.prompt, input.runOptions.collaborationMode), input.attachments);
1152
- const userMessage = appendMessage(input.messagesByThreadId, activeThreadId, {
1153
- role: "user",
1154
- content: displayPrompt,
1155
- details: attachmentDetails(input.attachments)
1156
- });
1157
- let threadSummary = updateThread(input.threads, input.messagesByThreadId, activeThreadId, {
1158
- state: "running",
1159
- lastPrompt: displayPrompt,
1160
- lastError: void 0,
1161
- title: maybeReplaceDefaultTitle(input.threads.get(activeThreadId)?.title, displayPrompt)
1162
- });
1163
- sendSse(input.controller, input.encoder, input.secureSession, {
1164
- type: "thread.message.created",
1165
- thread: threadSummary,
1166
- message: userMessage
1167
- });
1168
- sendSse(input.controller, input.encoder, input.secureSession, {
1169
- type: "thread.state.changed",
1170
- thread: threadSummary
1171
- });
1172
- const options = buildThreadOptions(input.threadOptions, input.runOptions);
1173
- const thread = hasExplicitRunOptions(input.runOptions) ? input.codex.resumeThread(activeThreadId, options) : input.liveThreads.get(activeThreadId) ?? input.codex.resumeThread(activeThreadId, options);
1174
- input.liveThreads.set(activeThreadId, thread);
1175
- if (!thread.runStreamed) {
1176
- const result = stringifyRunResult(await thread.run(runPrompt));
1177
- activeThreadId = replaceLocalThreadId(input.threads, input.messagesByThreadId, input.liveThreads, activeThreadId, getThreadId(thread));
1178
- assistantMessage = appendMessage(input.messagesByThreadId, activeThreadId, {
1179
- role: "assistant",
1180
- content: result,
1181
- state: "completed"
1182
- });
1183
- threadSummary = updateThread(input.threads, input.messagesByThreadId, activeThreadId, {
1184
- state: "completed",
1185
- lastResult: result
1186
- });
1187
- sendSse(input.controller, input.encoder, input.secureSession, {
1188
- type: "thread.message.completed",
1189
- thread: threadSummary,
1190
- message: assistantMessage
1191
- });
1192
- sendSse(input.controller, input.encoder, input.secureSession, {
1193
- type: "thread.state.changed",
1194
- thread: threadSummary
1195
- });
1196
- return;
1197
- }
1198
- const streamed = await thread.runStreamed(runPrompt);
1199
- activeThreadId = replaceLocalThreadId(input.threads, input.messagesByThreadId, input.liveThreads, activeThreadId, getThreadId(thread));
1200
- assistantMessage = appendMessage(input.messagesByThreadId, activeThreadId, {
1201
- role: "assistant",
1202
- content: "",
1203
- state: "streaming"
1204
- });
1205
- threadSummary = updateThread(input.threads, input.messagesByThreadId, activeThreadId, { state: "running" });
1206
- sendSse(input.controller, input.encoder, input.secureSession, {
1207
- type: "thread.message.created",
1208
- thread: threadSummary,
1209
- message: assistantMessage
1210
- });
1211
- for await (const event of streamed.events) {
1212
- const kind = classifyStreamEvent(event);
1213
- const text = extractStreamText(event);
1214
- if (kind === "error") throw new Error(text ?? "Codex run failed.");
1215
- if (!text) continue;
1216
- if (kind === "assistant") {
1217
- assistantMessage = appendMessageDelta(input.messagesByThreadId, activeThreadId, assistantMessage.id, text);
1218
- updateThread(input.threads, input.messagesByThreadId, activeThreadId, {
1219
- state: "running",
1220
- lastResult: assistantMessage.content
1221
- });
1222
- sendSse(input.controller, input.encoder, input.secureSession, {
1223
- type: "thread.message.delta",
1224
- threadId: activeThreadId,
1225
- messageId: assistantMessage.id,
1226
- delta: text
1227
- });
1228
- } else {
1229
- const structured = structuredStreamMessage(kind, event, text);
1230
- const statusMessage = appendMessage(input.messagesByThreadId, activeThreadId, {
1231
- role: kind,
1232
- kind: structured.kind,
1233
- content: structured.content,
1234
- details: structured.details,
1235
- state: "completed"
1236
- });
1237
- threadSummary = updateThread(input.threads, input.messagesByThreadId, activeThreadId, { state: "running" });
1238
- sendSse(input.controller, input.encoder, input.secureSession, {
1239
- type: "thread.message.created",
1240
- thread: threadSummary,
1241
- message: statusMessage
1242
- });
1243
- }
1244
- }
1245
- assistantMessage = updateMessage(input.messagesByThreadId, activeThreadId, assistantMessage.id, { state: "completed" });
1246
- threadSummary = updateThread(input.threads, input.messagesByThreadId, activeThreadId, {
1247
- state: "completed",
1248
- lastResult: assistantMessage.content,
1249
- lastError: void 0
1250
- });
1251
- sendSse(input.controller, input.encoder, input.secureSession, {
1252
- type: "thread.message.completed",
1253
- thread: threadSummary,
1254
- message: assistantMessage
1255
- });
1256
- sendSse(input.controller, input.encoder, input.secureSession, {
1257
- type: "thread.state.changed",
1258
- thread: threadSummary
1259
- });
1260
- } catch (error) {
1261
- input.steeringThreads.delete(activeThreadId);
1262
- const threadSummary = updateThread(input.threads, input.messagesByThreadId, activeThreadId, {
1263
- state: "failed",
1264
- lastError: errorMessage(error)
1265
- });
1266
- const errorBody = apiError("codex_run_failed", threadSummary.lastError ?? "Codex run failed.");
1267
- appendMessage(input.messagesByThreadId, activeThreadId, {
1268
- role: "error",
1269
- content: errorBody.error.message,
1270
- state: "failed"
1271
- });
1272
- sendSse(input.controller, input.encoder, input.secureSession, {
1273
- type: "thread.error",
1274
- thread: threadSummary,
1275
- error: errorBody.error
1276
- });
1277
- } finally {
1278
- input.controller.close();
1279
- }
1280
- }
1281
- async function startAppServerTurn(appServer, threadId, input) {
1282
- const runtime = resolveAppServerRuntime(input.runOptions.runtimeMode, input.workspacePath);
1283
- return appServer.startTurn({
1284
- approvalPolicy: runtime.approvalPolicy,
1285
- collaborationMode: appServerCollaborationMode(input.runOptions),
1286
- cwd: input.workspacePath,
1287
- effort: input.runOptions.reasoningEffort ?? null,
1288
- input: [{
1289
- type: "text",
1290
- text: input.prompt,
1291
- text_elements: []
1292
- }, ...input.attachments.map((attachment) => ({
1293
- type: "image",
1294
- url: attachment.dataUri
1295
- }))],
1296
- model: input.runOptions.model ?? null,
1297
- sandboxPolicy: runtime.sandboxPolicy,
1298
- threadId
1299
- });
1300
- }
1301
- function shiftQueuedInput(queuedInputsByThreadId, threadId) {
1302
- const queuedInputs = queuedInputsByThreadId.get(threadId);
1303
- const nextInput = queuedInputs?.shift();
1304
- if (!queuedInputs || queuedInputs.length === 0) queuedInputsByThreadId.delete(threadId);
1305
- return nextInput;
1306
- }
1307
- async function runAppServerPromptStreamed(input) {
1308
- let activeThreadId = input.threadId;
1309
- let activeTurnId;
1310
- let assistantMessageId;
1311
- const prompt = promptWithAttachments(input.prompt, input.attachments);
1312
- const userMessage = appendMessage(input.messagesByThreadId, activeThreadId, {
1313
- role: "user",
1314
- content: prompt,
1315
- details: attachmentDetails(input.attachments)
1316
- });
1317
- let threadSummary = updateThread(input.threads, input.messagesByThreadId, activeThreadId, {
1318
- state: "running",
1319
- lastPrompt: prompt,
1320
- lastError: void 0,
1321
- title: maybeReplaceDefaultTitle(input.threads.get(activeThreadId)?.title, prompt)
1322
- });
1323
- sendSse(input.controller, input.encoder, input.secureSession, {
1324
- type: "thread.message.created",
1325
- thread: threadSummary,
1326
- message: userMessage
1327
- });
1328
- sendSse(input.controller, input.encoder, input.secureSession, {
1329
- type: "thread.state.changed",
1330
- thread: threadSummary
1331
- });
1332
- const cleanupRequestHandler = input.appServer.onRequest((request) => {
1333
- if (!isApprovalServerRequest(request.method)) {
1334
- input.appServer.rejectRequest(request.id, -32601, `${request.method} is not supported by Codex Relay mobile yet.`);
1335
- return;
1336
- }
1337
- const approval = approvalMessageFromRequest(request);
1338
- if (!approval || approval.threadId !== activeThreadId) {
1339
- input.appServer.rejectRequest(request.id, -32602, "Approval request is malformed.");
1340
- return;
1341
- }
1342
- input.pendingApprovals.set(approval.approvalId, {
1343
- appServer: input.appServer,
1344
- kind: approval.kind,
1345
- method: request.method,
1346
- requestId: request.id,
1347
- threadId: activeThreadId
1348
- });
1349
- const message = appendMessage(input.messagesByThreadId, activeThreadId, {
1350
- role: "status",
1351
- kind: approval.kind === "structuredUserInput" ? "structuredUserInput" : "approvalRequest",
1352
- content: approval.content,
1353
- details: approval.details,
1354
- state: "completed",
1355
- turnId: approval.turnId
1356
- });
1357
- threadSummary = updateThread(input.threads, input.messagesByThreadId, activeThreadId, { state: "running" });
1358
- sendSse(input.controller, input.encoder, input.secureSession, {
1359
- type: "thread.message.created",
1360
- thread: threadSummary,
1361
- message
1362
- });
1363
- });
1364
- const completed = new Promise((resolve, reject) => {
1365
- const cleanupNotificationHandler = input.appServer.onNotification((notification) => {
1366
- const params = recordParams(notification);
1367
- const threadId = firstString(params, ["threadId"]);
1368
- if (threadId && threadId !== activeThreadId) return;
1369
- try {
1370
- switch (notification.method) {
1371
- case "thread/status/changed": {
1372
- const status = params?.status;
1373
- threadSummary = updateThread(input.threads, input.messagesByThreadId, activeThreadId, { state: mapAppServerThreadState(status) });
1374
- sendSse(input.controller, input.encoder, input.secureSession, {
1375
- type: "thread.state.changed",
1376
- thread: threadSummary
1377
- });
1378
- return;
1379
- }
1380
- case "turn/started":
1381
- activeTurnId = firstString(params, ["turnId"]) ?? turnIdFromParams(params);
1382
- return;
1383
- case "item/started":
1384
- case "item/completed": {
1385
- const item = params?.item;
1386
- if (!item || typeof item !== "object") return;
1387
- const turnId = firstString(params, ["turnId"]) ?? activeTurnId;
1388
- const message = upsertAppServerItemMessage(input.messagesByThreadId, activeThreadId, turnId, item);
1389
- if (!message) return;
1390
- if (message.role === "assistant") assistantMessageId = message.id;
1391
- threadSummary = updateThread(input.threads, input.messagesByThreadId, activeThreadId, {
1392
- state: "running",
1393
- lastResult: message.role === "assistant" ? message.content : void 0
1394
- });
1395
- sendSse(input.controller, input.encoder, input.secureSession, {
1396
- type: notification.method === "item/completed" && message.role === "assistant" ? "thread.message.completed" : "thread.message.created",
1397
- thread: threadSummary,
1398
- message
1399
- });
1400
- return;
1401
- }
1402
- case "item/agentMessage/delta": {
1403
- const itemId = firstString(params, ["itemId"]);
1404
- const delta = firstString(params, ["delta"]);
1405
- if (!itemId || !delta) return;
1406
- if (!input.messagesByThreadId.get(activeThreadId)?.some((item) => item.id === itemId)) appendMessageWithId(input.messagesByThreadId, activeThreadId, itemId, {
1407
- role: "assistant",
1408
- content: "",
1409
- state: "streaming",
1410
- turnId: firstString(params, ["turnId"]) ?? activeTurnId
1411
- });
1412
- assistantMessageId = itemId;
1413
- const message = appendMessageDelta(input.messagesByThreadId, activeThreadId, itemId, delta);
1414
- updateThread(input.threads, input.messagesByThreadId, activeThreadId, {
1415
- state: "running",
1416
- lastResult: message.content
1417
- });
1418
- sendSse(input.controller, input.encoder, input.secureSession, {
1419
- type: "thread.message.delta",
1420
- threadId: activeThreadId,
1421
- messageId: itemId,
1422
- delta
1423
- });
1424
- return;
1425
- }
1426
- case "turn/plan/updated": {
1427
- const explanation = firstString(params, ["explanation"]);
1428
- const plan = Array.isArray(params?.plan) ? params.plan : [];
1429
- const content = [explanation, ...plan.map((step) => planStepText(step))].filter(Boolean).join("\n");
1430
- const message = appendMessage(input.messagesByThreadId, activeThreadId, {
1431
- role: "status",
1432
- kind: "plan",
1433
- content: content || "Plan updated",
1434
- details: {
1435
- explanation,
1436
- plan
1437
- },
1438
- state: "completed",
1439
- turnId: firstString(params, ["turnId"]) ?? activeTurnId
1440
- });
1441
- threadSummary = updateThread(input.threads, input.messagesByThreadId, activeThreadId, { state: "running" });
1442
- sendSse(input.controller, input.encoder, input.secureSession, {
1443
- type: "thread.message.created",
1444
- thread: threadSummary,
1445
- message
1446
- });
1447
- return;
1448
- }
1449
- case "turn/completed": {
1450
- const state = turnStatus(params) === "failed" ? "failed" : "completed";
1451
- if (assistantMessageId) {
1452
- const completedMessage = updateMessage(input.messagesByThreadId, activeThreadId, assistantMessageId, { state: "completed" });
1453
- sendSse(input.controller, input.encoder, input.secureSession, {
1454
- type: "thread.message.completed",
1455
- thread: threadSummary,
1456
- message: completedMessage
1457
- });
1458
- }
1459
- const nextQueuedInput = state === "completed" ? shiftQueuedInput(input.queuedInputsByThreadId, activeThreadId) : void 0;
1460
- threadSummary = updateThread(input.threads, input.messagesByThreadId, activeThreadId, {
1461
- state,
1462
- lastError: state === "failed" ? turnErrorMessage(params) : void 0
1463
- });
1464
- sendSse(input.controller, input.encoder, input.secureSession, {
1465
- type: "thread.state.changed",
1466
- thread: threadSummary
1467
- });
1468
- if (nextQueuedInput) {
1469
- assistantMessageId = void 0;
1470
- input.steeringThreads.add(activeThreadId);
1471
- threadSummary = updateThread(input.threads, input.messagesByThreadId, activeThreadId, {
1472
- state: "running",
1473
- lastPrompt: promptWithAttachments(nextQueuedInput.prompt, nextQueuedInput.attachments),
1474
- lastError: void 0
1475
- });
1476
- sendSse(input.controller, input.encoder, input.secureSession, {
1477
- type: "thread.state.changed",
1478
- thread: threadSummary
1479
- });
1480
- startAppServerTurn(input.appServer, activeThreadId, nextQueuedInput).then((turn) => {
1481
- activeTurnId = turn.id;
1482
- }).catch((error) => {
1483
- cleanupNotificationHandler();
1484
- reject(error);
1485
- });
1486
- return;
1487
- }
1488
- input.steeringThreads.delete(activeThreadId);
1489
- cleanupNotificationHandler();
1490
- resolve();
1491
- return;
1492
- }
1493
- }
1494
- } catch (error) {
1495
- cleanupNotificationHandler();
1496
- reject(error);
1497
- }
1498
- });
1499
- });
1500
- try {
1501
- activeTurnId = (await startAppServerTurn(input.appServer, activeThreadId, {
1502
- attachments: input.attachments,
1503
- prompt,
1504
- runOptions: input.runOptions,
1505
- workspacePath: input.workspacePath
1506
- })).id;
1507
- await completed;
1508
- } catch (error) {
1509
- const threadSummary = updateThread(input.threads, input.messagesByThreadId, activeThreadId, {
1510
- state: "failed",
1511
- lastError: errorMessage(error)
1512
- });
1513
- const errorBody = apiError("codex_run_failed", threadSummary.lastError ?? "Codex run failed.");
1514
- appendMessage(input.messagesByThreadId, activeThreadId, {
1515
- role: "error",
1516
- content: errorBody.error.message,
1517
- state: "failed"
1518
- });
1519
- sendSse(input.controller, input.encoder, input.secureSession, {
1520
- type: "thread.error",
1521
- thread: threadSummary,
1522
- error: errorBody.error
1523
- });
1524
- } finally {
1525
- cleanupRequestHandler();
1526
- input.controller.close();
1527
- }
1528
- }
1529
- async function parseRequestJson(c, pairing, secureSessionsByTokenHash, schema) {
1530
- let payload;
1531
- try {
1532
- payload = await c.req.raw.json();
1533
- } catch {
1534
- payload = {};
1535
- }
1536
- const secureSession = getSecureSessionForRequest(c, pairing, secureSessionsByTokenHash);
1537
- if (secureSession) {
1538
- const envelope = EncryptedPayloadSchema.safeParse(payload);
1539
- if (!envelope.success) return schema.safeParse({ __invalidEncryptedPayload: true });
1540
- try {
1541
- payload = JSON.parse(decryptFromMobile(secureSession.session, envelope.data));
1542
- await secureSession.persist();
1543
- } catch {
1544
- payload = { __invalidEncryptedPayload: true };
1545
- }
1546
- }
1547
- return schema.safeParse(payload);
1548
- }
1549
- async function parsePlainJson(request, schema) {
1550
- let payload;
1551
- try {
1552
- payload = await request.json();
1553
- } catch {
1554
- payload = {};
1555
- }
1556
- return schema.safeParse(payload);
1557
- }
1558
- async function secureJson(c, pairing, secureSessionsByTokenHash, payload, status) {
1559
- const secureSession = getSecureSessionForRequest(c, pairing, secureSessionsByTokenHash);
1560
- if (!secureSession) return c.json(payload, status);
1561
- const encrypted = EncryptedPayloadSchema.parse(encryptForMobile(secureSession.session, JSON.stringify(payload)));
1562
- await secureSession.persist();
1563
- return c.json(encrypted, status);
1564
- }
1565
- function getSecureSessionForRequest(c, pairing, secureSessionsByTokenHash) {
1566
- const token = parseBearerToken(c.req.header("authorization"));
1567
- if (!token || !pairing) return;
1568
- const tokenHash = pairing.hashClientToken(token);
1569
- const session = secureSessionsByTokenHash.get(tokenHash);
1570
- return session ? createSecureSessionHandle(pairing, tokenHash, session) : void 0;
1571
- }
1572
- function createSecureSessionHandle(pairing, tokenHash, session) {
1573
- let pendingPersist = Promise.resolve();
1574
- return {
1575
- persist: () => {
1576
- pendingPersist = pendingPersist.catch(() => void 0).then(() => pairing.sessions.updateSecureSession(tokenHash, session));
1577
- return pendingPersist;
1578
- },
1579
- session,
1580
- tokenHash
1581
- };
1582
- }
1583
- function sortedThreads(threads) {
1584
- return [...threads.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
1585
- }
1586
- async function ensureKnownThread(input) {
1587
- const knownThread = input.threads.get(input.threadId);
1588
- if (knownThread) return knownThread;
1589
- if (!input.appServer) return;
1590
- try {
1591
- const appServerThread = await input.appServer.readThread(input.threadId);
1592
- const thread = mapAppServerThread(appServerThread);
1593
- input.threads.set(input.threadId, thread);
1594
- input.messagesByThreadId.set(input.threadId, mapAppServerMessages(appServerThread));
1595
- return thread;
1596
- } catch {
1597
- return;
1598
- }
1599
- }
1600
- function appendMessage(messagesByThreadId, threadId, input) {
1601
- const now = (/* @__PURE__ */ new Date()).toISOString();
1602
- const message = ChatMessageSchema.parse({
1603
- id: `msg-${crypto.randomUUID()}`,
1604
- threadId,
1605
- role: input.role,
1606
- kind: input.kind,
1607
- content: input.content,
1608
- details: input.details,
1609
- createdAt: now,
1610
- updatedAt: now,
1611
- state: input.state,
1612
- turnId: input.turnId
1613
- });
1614
- const messages = messagesByThreadId.get(threadId) ?? [];
1615
- messages.push(message);
1616
- messagesByThreadId.set(threadId, messages);
1617
- return message;
1618
- }
1619
- function appendMessageWithId(messagesByThreadId, threadId, id, input) {
1620
- const now = (/* @__PURE__ */ new Date()).toISOString();
1621
- const message = ChatMessageSchema.parse({
1622
- id,
1623
- threadId,
1624
- role: input.role,
1625
- kind: input.kind,
1626
- content: input.content,
1627
- details: input.details,
1628
- createdAt: now,
1629
- updatedAt: now,
1630
- state: input.state,
1631
- turnId: input.turnId
1632
- });
1633
- const messages = messagesByThreadId.get(threadId) ?? [];
1634
- messages.push(message);
1635
- messagesByThreadId.set(threadId, messages);
1636
- return message;
1637
- }
1638
- function upsertAppServerItemMessage(messagesByThreadId, threadId, turnId, item) {
1639
- const message = mapAppServerItem(threadId, appServerTurnShell(turnId), item);
1640
- if (!message) return;
1641
- if (messagesByThreadId.get(threadId)?.some((candidate) => candidate.id === item.id)) return updateMessage(messagesByThreadId, threadId, item.id, message);
1642
- return appendMessageWithId(messagesByThreadId, threadId, item.id, {
1643
- role: message.role,
1644
- kind: message.kind,
1645
- content: message.content,
1646
- details: message.details,
1647
- state: message.state,
1648
- turnId: message.turnId
1649
- });
1650
- }
1651
- function appendMessageDelta(messagesByThreadId, threadId, messageId, delta) {
1652
- const existing = messagesByThreadId.get(threadId)?.find((message) => message.id === messageId);
1653
- return updateMessage(messagesByThreadId, threadId, messageId, {
1654
- content: `${existing?.content ?? ""}${delta}`,
1655
- state: "streaming"
1656
- });
1657
- }
1658
- function updateMessage(messagesByThreadId, threadId, messageId, update) {
1659
- const messages = messagesByThreadId.get(threadId) ?? [];
1660
- const index = messages.findIndex((message) => message.id === messageId);
1661
- if (index === -1) throw new Error(`Unknown message: ${messageId}`);
1662
- const next = ChatMessageSchema.parse({
1663
- ...messages[index],
1664
- ...update,
1665
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1666
- });
1667
- messages[index] = next;
1668
- return next;
1669
- }
1670
- function replaceLocalThreadId(threads, messagesByThreadId, liveThreads, currentThreadId, sdkThreadId) {
1671
- if (!sdkThreadId || sdkThreadId === currentThreadId) return currentThreadId;
1672
- const metadata = threads.get(currentThreadId);
1673
- const thread = liveThreads.get(currentThreadId);
1674
- const messages = messagesByThreadId.get(currentThreadId) ?? [];
1675
- if (!metadata) return sdkThreadId;
1676
- threads.delete(currentThreadId);
1677
- liveThreads.delete(currentThreadId);
1678
- messagesByThreadId.delete(currentThreadId);
1679
- threads.set(sdkThreadId, {
1680
- ...metadata,
1681
- id: sdkThreadId
1682
- });
1683
- messagesByThreadId.set(sdkThreadId, messages.map((message) => ({
1684
- ...message,
1685
- threadId: sdkThreadId
1686
- })));
1687
- if (thread) liveThreads.set(sdkThreadId, thread);
1688
- return sdkThreadId;
1689
- }
1690
- function updateThread(threads, messagesByThreadId, threadId, update) {
1691
- const existing = threads.get(threadId);
1692
- if (!existing) throw new Error(`Unknown thread: ${threadId}`);
1693
- const messages = messagesByThreadId.get(threadId) ?? [];
1694
- const lastMessage = [...messages].reverse().find((message) => message.role !== "status");
1695
- const next = ThreadSummarySchema.parse({
1696
- ...existing,
1697
- ...update,
1698
- messageCount: messages.length,
1699
- lastMessagePreview: lastMessage?.content ? preview(lastMessage.content) : existing.lastMessagePreview,
1700
- lastActivityAt: lastMessage?.updatedAt ?? lastMessage?.createdAt ?? existing.lastActivityAt,
1701
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1702
- });
1703
- threads.set(threadId, next);
1704
- return next;
1705
- }
1706
- function sendSse(controller, encoder, secureSession, event) {
1707
- const parsed = StreamThreadRunEventSchema.parse(event);
1708
- const data = secureSession ? EncryptedPayloadSchema.parse(encryptForMobile(secureSession.session, JSON.stringify(parsed))) : parsed;
1709
- if (secureSession) secureSession.persist().catch(() => void 0);
1710
- controller.enqueue(encoder.encode(`event: ${parsed.type}\n`));
1711
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
1712
- }
1713
- function startWebPreviewTargetMonitor({ bridgeUrl, send }) {
1714
- const urls = webPreviewCandidateUrls(bridgeUrl);
1715
- const seenUrls = /* @__PURE__ */ new Set();
1716
- let stopped = false;
1717
- async function scan() {
1718
- if (stopped) return;
1719
- const targets = await detectWebPreviewTargets(urls);
1720
- for (const target of targets) {
1721
- if (stopped || seenUrls.has(target.url)) continue;
1722
- seenUrls.add(target.url);
1723
- try {
1724
- send(target);
1725
- } catch {
1726
- stopped = true;
1727
- return;
1728
- }
1729
- }
1730
- }
1731
- scan();
1732
- const interval = setInterval(() => void scan(), 1500);
1733
- return () => {
1734
- stopped = true;
1735
- clearInterval(interval);
1736
- };
1737
- }
1738
- function webPreviewCandidateUrls(bridgeUrl) {
1739
- const bridge = new URL(bridgeUrl);
1740
- const bridgePort = Number(bridge.port);
1741
- return webPreviewCandidatePorts().filter((port) => port !== bridgePort).map((port) => {
1742
- const url = new URL(bridge.toString());
1743
- url.port = String(port);
1744
- url.pathname = "/";
1745
- url.search = "";
1746
- url.hash = "";
1747
- return url.toString().replace(/\/$/, "");
1748
- });
1749
- }
1750
- function webPreviewCandidatePorts() {
1751
- const configured = process.env.CODEX_RELAY_WEB_PREVIEW_PORTS;
1752
- if (!configured) return defaultWebPreviewPorts;
1753
- const ports = configured.split(",").map((value) => Number(value.trim())).filter((port) => Number.isInteger(port) && port > 0 && port < 65536);
1754
- return ports.length > 0 ? ports : defaultWebPreviewPorts;
1755
- }
1756
- async function detectWebPreviewTargets(urls) {
1757
- return (await Promise.all(urls.map((url) => probeWebPreviewTarget(url)))).filter((target) => Boolean(target));
1758
- }
1759
- async function probeWebPreviewTarget(url) {
1760
- const controller = new AbortController();
1761
- const timeout = setTimeout(() => controller.abort(), 700);
1762
- try {
1763
- const response = await fetch(url, { signal: controller.signal });
1764
- if (!response.ok) return;
1765
- const contentType = response.headers.get("content-type") ?? "";
1766
- const text = await response.text();
1767
- if (!contentType.includes("text/html") && !looksLikeHtml(text)) return;
1768
- return {
1769
- kind: "web",
1770
- url,
1771
- port: Number(new URL(url).port),
1772
- label: webPreviewLabel(text),
1773
- source: "detected-port",
1774
- confidence: "high",
1775
- detectedAt: (/* @__PURE__ */ new Date()).toISOString()
1776
- };
1777
- } catch {
1778
- return;
1779
- } finally {
1780
- clearTimeout(timeout);
1781
- }
1782
- }
1783
- function looksLikeHtml(value) {
1784
- return /^\s*(<!doctype html|<html[\s>])/i.test(value);
1785
- }
1786
- function webPreviewLabel(html) {
1787
- if (html.includes("/@vite/client")) return "Vite";
1788
- if (html.includes("__next")) return "Next.js";
1789
- if (html.includes("expo-router") || html.includes("Expo")) return "Expo";
1790
- return "Web preview";
1791
- }
1792
- function titleFromPrompt(prompt) {
1793
- if (!prompt) return;
1794
- const firstLine = prompt.trim().split(/\r?\n/, 1)[0];
1795
- return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
1796
- }
1797
- function maybeReplaceDefaultTitle(currentTitle, prompt) {
1798
- return !currentTitle || currentTitle === "New Codex thread" ? titleFromPrompt(prompt) : currentTitle;
1799
- }
1800
- function promptWithAttachments(prompt, attachments) {
1801
- if (attachments.length === 0) return prompt;
1802
- return `${prompt}\n\n${attachments.map((attachment, index) => {
1803
- const name = attachment.name ? ` (${attachment.name})` : "";
1804
- return `Attached image ${index + 1}${name}:\n${attachment.dataUri}`;
1805
- }).join("\n\n")}`;
1806
- }
1807
- function promptForCollaborationMode(prompt, collaborationMode) {
1808
- if (collaborationMode !== "plan") return prompt;
1809
- return `${planModePromptPrefix}\n\nUser request:\n${prompt}`;
1810
- }
1811
- function appServerCollaborationMode(options) {
1812
- if (options.collaborationMode !== "plan" || !options.model) return null;
1813
- return {
1814
- mode: "plan",
1815
- settings: {
1816
- developer_instructions: null,
1817
- model: options.model,
1818
- reasoning_effort: options.reasoningEffort ?? null
1819
- }
1820
- };
1821
- }
1822
- function attachmentDetails(attachments) {
1823
- if (attachments.length === 0) return;
1824
- return { attachments: attachments.map((attachment) => ({
1825
- mimeType: attachment.mimeType,
1826
- name: attachment.name,
1827
- type: attachment.type
1828
- })) };
1829
- }
1830
- function buildThreadOptions(base, options) {
1831
- const runtime = resolveRuntimeOptions(options.runtimeMode);
1832
- return {
1833
- ...base,
1834
- ...runtime,
1835
- ...options.model ? { model: options.model } : {},
1836
- ...options.reasoningEffort ? { modelReasoningEffort: options.reasoningEffort } : {},
1837
- ...options.approvalPolicy ? { approvalPolicy: options.approvalPolicy } : {},
1838
- ...options.sandboxMode ? { sandboxMode: options.sandboxMode } : {}
1839
- };
1840
- }
1841
- function resolveRuntimeOptions(runtimeMode) {
1842
- switch (runtimeMode) {
1843
- case "auto": return {
1844
- approvalPolicy: "on-failure",
1845
- sandboxMode: "workspace-write"
1846
- };
1847
- case "full-access": return {
1848
- approvalPolicy: "never",
1849
- sandboxMode: "danger-full-access"
1850
- };
1851
- default: return {
1852
- approvalPolicy: "on-request",
1853
- sandboxMode: "workspace-write"
1854
- };
1855
- }
1856
- }
1857
- function resolveAppServerRuntime(runtimeMode, workspacePath) {
1858
- const runtime = resolveRuntimeOptions(runtimeMode) ?? {};
1859
- const sandbox = runtime.sandboxMode ?? "workspace-write";
1860
- return {
1861
- approvalPolicy: runtime.approvalPolicy ?? "on-request",
1862
- sandbox,
1863
- sandboxPolicy: sandboxPolicyForMode(sandbox, workspacePath)
1864
- };
1865
- }
1866
- function sandboxPolicyForMode(sandboxMode, workspacePath) {
1867
- if (sandboxMode === "danger-full-access") return { type: "dangerFullAccess" };
1868
- if (sandboxMode === "read-only") return {
1869
- type: "readOnly",
1870
- access: { type: "fullAccess" },
1871
- networkAccess: false
1872
- };
1873
- return {
1874
- type: "workspaceWrite",
1875
- writableRoots: [workspacePath],
1876
- readOnlyAccess: { type: "fullAccess" },
1877
- networkAccess: false,
1878
- excludeTmpdirEnvVar: false,
1879
- excludeSlashTmp: false
1880
- };
1881
- }
1882
- function hasExplicitRunOptions(options) {
1883
- return Boolean(options.model || options.reasoningEffort || options.approvalPolicy || options.sandboxMode || options.collaborationMode === "plan" || options.runtimeMode);
1884
- }
1885
- function mapAppServerThread(thread) {
1886
- const createdAt = fromUnixSeconds(thread.createdAt);
1887
- const updatedAt = fromUnixSeconds(thread.updatedAt);
1888
- return ThreadSummarySchema.parse({
1889
- id: thread.id,
1890
- title: thread.name ?? preview(thread.preview || "Untitled thread"),
1891
- createdAt,
1892
- updatedAt,
1893
- state: mapAppServerThreadState(thread.status),
1894
- model: thread.modelProvider,
1895
- cwd: String(thread.cwd),
1896
- source: thread.source,
1897
- messageCount: countThreadMessages(thread),
1898
- lastMessagePreview: thread.preview ? preview(thread.preview) : void 0,
1899
- lastActivityAt: updatedAt
1900
- });
1901
- }
1902
- function mapAppServerMessages(thread) {
1903
- const messages = [];
1904
- for (const turn of thread.turns ?? []) for (const item of turn.items ?? []) {
1905
- const message = mapAppServerItem(thread.id, turn, item);
1906
- if (message) messages.push(message);
1907
- }
1908
- return messages;
1909
- }
1910
- function mapAppServerItem(threadId, turn, item) {
1911
- const timestamp = fromUnixSeconds(turn.startedAt ?? turn.completedAt ?? Date.now() / 1e3);
1912
- const base = {
1913
- id: item.id,
1914
- threadId,
1915
- createdAt: timestamp,
1916
- updatedAt: turn.completedAt ? fromUnixSeconds(turn.completedAt) : timestamp,
1917
- turnId: turn.id,
1918
- state: "completed"
1919
- };
1920
- switch (item.type) {
1921
- case "userMessage": {
1922
- const userItem = item;
1923
- return ChatMessageSchema.parse({
1924
- ...base,
1925
- role: "user",
1926
- content: userItem.content.map((content) => content.text ?? content.path ?? content.url ?? "").filter(Boolean).join("\n\n")
1927
- });
1928
- }
1929
- case "agentMessage": {
1930
- const agentItem = item;
1931
- return ChatMessageSchema.parse({
1932
- ...base,
1933
- role: "assistant",
1934
- content: agentItem.text
1935
- });
1936
- }
1937
- case "reasoning": {
1938
- const reasoningItem = item;
1939
- const summary = compactStringList(reasoningItem.summary);
1940
- const content = compactStringList(reasoningItem.content);
1941
- const text = [...summary, ...content].join("\n\n") || "Reasoning";
1942
- return ChatMessageSchema.parse({
1943
- ...base,
1944
- role: "reasoning",
1945
- kind: "thinking",
1946
- content: text,
1947
- details: {
1948
- summary,
1949
- content
1950
- }
1951
- });
1952
- }
1953
- case "commandExecution": {
1954
- const commandItem = item;
1955
- return ChatMessageSchema.parse({
1956
- ...base,
1957
- role: "tool",
1958
- kind: "commandExecution",
1959
- content: commandItem.command,
1960
- details: {
1961
- command: commandItem.command,
1962
- cwd: commandItem.cwd ?? void 0,
1963
- exitCode: commandItem.exitCode ?? void 0,
1964
- output: commandItem.aggregatedOutput ?? void 0,
1965
- status: commandItem.status ?? void 0
1966
- }
1967
- });
1968
- }
1969
- case "fileChange": {
1970
- const fileItem = item;
1971
- const changes = fileItem.changes ?? [];
1972
- return ChatMessageSchema.parse({
1973
- ...base,
1974
- role: "tool",
1975
- kind: "fileChange",
1976
- content: summarizeFileChanges(changes),
1977
- details: {
1978
- changes,
1979
- patch: fileItem.patch ?? void 0
1980
- }
1981
- });
1982
- }
1983
- case "mcpToolCall": {
1984
- const toolItem = item;
1985
- return ChatMessageSchema.parse({
1986
- ...base,
1987
- role: "tool",
1988
- kind: "toolActivity",
1989
- content: `${toolItem.server}.${toolItem.tool}`,
1990
- details: {
1991
- server: toolItem.server,
1992
- status: toolItem.status ?? void 0,
1993
- tool: toolItem.tool
1994
- }
1995
- });
1996
- }
1997
- case "webSearch": {
1998
- const searchItem = item;
1999
- return ChatMessageSchema.parse({
2000
- ...base,
2001
- role: "tool",
2002
- kind: "webSearch",
2003
- content: searchItem.query,
2004
- details: {
2005
- query: searchItem.query,
2006
- status: searchItem.status ?? void 0
2007
- }
2008
- });
2009
- }
2010
- default: return mapUnknownAppServerItem(threadId, turn, item);
2011
- }
2012
- }
2013
- function mapUnknownAppServerItem(threadId, turn, item) {
2014
- const timestamp = fromUnixSeconds(turn.startedAt ?? turn.completedAt ?? Date.now() / 1e3);
2015
- const type = "type" in item ? String(item.type) : "unknown";
2016
- const kind = kindFromProtocolType(type) ?? "unknown";
2017
- return ChatMessageSchema.parse({
2018
- id: item.id,
2019
- threadId,
2020
- role: "status",
2021
- kind,
2022
- content: type,
2023
- createdAt: timestamp,
2024
- updatedAt: turn.completedAt ? fromUnixSeconds(turn.completedAt) : timestamp,
2025
- turnId: turn.id,
2026
- state: "completed",
2027
- details: { type }
2028
- });
2029
- }
2030
- function appServerTurnShell(turnId) {
2031
- return {
2032
- id: turnId ?? "turn-live",
2033
- items: [],
2034
- status: "running",
2035
- startedAt: Date.now() / 1e3,
2036
- completedAt: null
2037
- };
2038
- }
2039
- function mapAppServerModel(model) {
2040
- return {
2041
- id: model.id,
2042
- model: model.model,
2043
- displayName: model.displayName,
2044
- description: model.description,
2045
- isDefault: Boolean(model.isDefault),
2046
- defaultReasoningEffort: model.defaultReasoningEffort,
2047
- supportedReasoningEfforts: model.supportedReasoningEfforts?.map((effort) => effort.reasoningEffort) ?? []
2048
- };
2049
- }
2050
- function normalizeRateLimitBuckets(rateLimits) {
2051
- const keyed = objectRecord(rateLimits.rateLimitsByLimitId);
2052
- if (keyed) return Object.values(keyed).flatMap((value) => {
2053
- const bucket = normalizeRateLimitBucket(value);
2054
- return bucket ? [bucket] : [];
2055
- });
2056
- const bucket = normalizeRateLimitBucket(rateLimits.rateLimits);
2057
- return bucket ? [bucket] : [];
2058
- }
2059
- function normalizeRateLimitBucket(value) {
2060
- const record = objectRecord(value);
2061
- if (!record) return;
2062
- const limitId = firstString(record, [
2063
- "limitId",
2064
- "limit_id",
2065
- "id"
2066
- ]);
2067
- if (!limitId) return;
2068
- return {
2069
- limitId,
2070
- limitName: firstString(record, [
2071
- "limitName",
2072
- "limit_name",
2073
- "name"
2074
- ]) ?? null,
2075
- planType: firstString(record, ["planType", "plan_type"]) ?? null,
2076
- primary: normalizeRateLimitWindow(record.primary),
2077
- secondary: normalizeRateLimitWindow(record.secondary),
2078
- rateLimitReachedType: firstString(record, ["rateLimitReachedType", "rate_limit_reached_type"]) ?? null
2079
- };
2080
- }
2081
- function normalizeRateLimitWindow(value) {
2082
- const record = objectRecord(value);
2083
- if (!record) return null;
2084
- const usedPercent = firstNumber(record, ["usedPercent", "used_percent"]);
2085
- if (usedPercent === void 0) return null;
2086
- return {
2087
- usedPercent: Math.max(0, Math.min(100, Math.round(usedPercent))),
2088
- windowDurationMins: firstNumber(record, ["windowDurationMins", "window_duration_mins"]) ?? null,
2089
- resetsAt: firstNumber(record, ["resetsAt", "resets_at"]) ?? null
2090
- };
2091
- }
2092
- function objectRecord(value) {
2093
- return value && typeof value === "object" ? value : void 0;
2094
- }
2095
- function fallbackModels() {
2096
- return [{
2097
- id: "gpt-5.5",
2098
- model: "gpt-5.5",
2099
- displayName: "GPT-5.5",
2100
- description: "Default Codex model",
2101
- isDefault: true,
2102
- defaultReasoningEffort: "medium",
2103
- supportedReasoningEfforts: [
2104
- { reasoningEffort: "low" },
2105
- { reasoningEffort: "medium" },
2106
- { reasoningEffort: "high" },
2107
- { reasoningEffort: "xhigh" }
2108
- ]
2109
- }];
2110
- }
2111
- function mapAppServerThreadState(status) {
2112
- if (typeof status === "string") {
2113
- if (status === "running" || status === "active") return "running";
2114
- if (status === "failed" || status === "systemError") return "failed";
2115
- if (status === "completed") return "completed";
2116
- }
2117
- if (status && typeof status === "object" && "type" in status) {
2118
- const type = String(status.type);
2119
- if (type === "active") return "running";
2120
- if (type === "systemError") return "failed";
2121
- }
2122
- return "idle";
2123
- }
2124
- function countThreadMessages(thread) {
2125
- return thread.turns?.reduce((count, turn) => count + turn.items.filter((item) => item.type === "userMessage" || item.type === "agentMessage").length, 0) ?? 0;
2126
- }
2127
- function fromUnixSeconds(value) {
2128
- return (/* @__PURE__ */ new Date(value * 1e3)).toISOString();
2129
- }
2130
- function preview(content) {
2131
- const normalized = content.replace(/\s+/g, " ").trim();
2132
- return normalized.length > 140 ? `${normalized.slice(0, 137)}...` : normalized;
2133
- }
2134
- function compactStringList(value) {
2135
- return value?.map((item) => item.trim()).filter(Boolean) ?? [];
2136
- }
2137
- function summarizeFileChanges(changes) {
2138
- if (changes.length === 0) return "Files changed";
2139
- const paths = changes.map((change) => change.path).filter(Boolean);
2140
- const shown = paths.slice(0, 3).join(", ");
2141
- const suffix = paths.length > 3 ? ` and ${paths.length - 3} more` : "";
2142
- return `${changes.length} file${changes.length === 1 ? "" : "s"} changed: ${shown}${suffix}`;
2143
- }
2144
- function structuredStreamMessage(role, event, fallbackContent) {
2145
- const item = eventItem(event);
2146
- const type = item?.type ? String(item.type) : void 0;
2147
- if (role === "reasoning") {
2148
- const summary = stringArray(item?.summary);
2149
- const content = stringArray(item?.content);
2150
- return {
2151
- kind: "thinking",
2152
- content: [...summary, ...content].join("\n\n") || fallbackContent,
2153
- details: {
2154
- content,
2155
- summary,
2156
- type
2157
- }
2158
- };
2159
- }
2160
- switch (type) {
2161
- case "command_execution": {
2162
- const command = firstString(item, ["command", "cmd"]) ?? fallbackContent;
2163
- return {
2164
- kind: "commandExecution",
2165
- content: command,
2166
- details: {
2167
- command,
2168
- cwd: firstString(item, ["cwd", "working_directory"]),
2169
- exitCode: firstNumber(item, ["exit_code", "exitCode"]),
2170
- output: firstString(item, [
2171
- "aggregated_output",
2172
- "aggregatedOutput",
2173
- "output"
2174
- ]),
2175
- status: firstString(item, ["status"]),
2176
- type
2177
- }
2178
- };
2179
- }
2180
- case "file_change": {
2181
- const changes = Array.isArray(item?.changes) ? item.changes : [];
2182
- return {
2183
- kind: "fileChange",
2184
- content: summarizeFileChanges(normalizeFileChanges(changes)),
2185
- details: {
2186
- changes,
2187
- patch: firstString(item, ["patch"]),
2188
- type
2189
- }
2190
- };
2191
- }
2192
- case "mcp_tool_call": return {
2193
- kind: "toolActivity",
2194
- content: [firstString(item, ["server"]), firstString(item, ["tool", "name"])].filter(Boolean).join(".") || fallbackContent,
2195
- details: {
2196
- server: firstString(item, ["server"]),
2197
- status: firstString(item, ["status"]),
2198
- tool: firstString(item, ["tool", "name"]),
2199
- type
2200
- }
2201
- };
2202
- case "web_search": return {
2203
- kind: "webSearch",
2204
- content: firstString(item, ["query"]) ?? fallbackContent,
2205
- details: {
2206
- query: firstString(item, ["query"]),
2207
- status: firstString(item, ["status"]),
2208
- type
2209
- }
2210
- };
2211
- default: return {
2212
- kind: kindFromProtocolType(type) ?? (role === "tool" ? "toolActivity" : "unknown"),
2213
- content: fallbackContent,
2214
- details: { type }
2215
- };
2216
- }
2217
- }
2218
- function isApprovalServerRequest(method) {
2219
- return method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval" || method === "item/permissions/requestApproval" || method === "item/tool/requestUserInput" || method === "mcpServer/elicitation/request" || method === "execCommandApproval" || method === "applyPatchApproval";
2220
- }
2221
- function approvalMessageFromRequest(request) {
2222
- const params = recordParams(request);
2223
- const threadId = firstString(params, ["threadId", "conversationId"]);
2224
- const turnId = firstString(params, ["turnId"]) ?? void 0;
2225
- if (!threadId) return;
2226
- const approvalId = `approval-${request.id}`;
2227
- switch (request.method) {
2228
- case "item/commandExecution/requestApproval": {
2229
- const command = firstString(params, ["command"]) ?? "Command execution";
2230
- return {
2231
- approvalId,
2232
- content: command,
2233
- details: {
2234
- approvalId,
2235
- approvalKind: "commandExecution",
2236
- command,
2237
- cwd: firstString(params, ["cwd"]),
2238
- reason: firstString(params, ["reason"]),
2239
- availableDecisions: Array.isArray(params?.availableDecisions) ? params.availableDecisions : void 0
2240
- },
2241
- kind: "commandExecution",
2242
- threadId,
2243
- turnId
2244
- };
2245
- }
2246
- case "execCommandApproval": {
2247
- const command = stringArray(params?.command).join(" ") || "Command execution";
2248
- return {
2249
- approvalId,
2250
- content: command,
2251
- details: {
2252
- approvalId,
2253
- approvalKind: "commandExecution",
2254
- command,
2255
- cwd: firstString(params, ["cwd"]),
2256
- reason: firstString(params, ["reason"])
2257
- },
2258
- kind: "commandExecution",
2259
- threadId,
2260
- turnId
2261
- };
2262
- }
2263
- case "item/fileChange/requestApproval": return {
2264
- approvalId,
2265
- content: firstString(params, ["reason"]) ?? "Approve file changes",
2266
- details: {
2267
- approvalId,
2268
- approvalKind: "fileChange",
2269
- grantRoot: firstString(params, ["grantRoot"]),
2270
- reason: firstString(params, ["reason"])
2271
- },
2272
- kind: "fileChange",
2273
- threadId,
2274
- turnId
2275
- };
2276
- case "applyPatchApproval": return {
2277
- approvalId,
2278
- content: firstString(params, ["reason"]) ?? "Approve file changes",
2279
- details: {
2280
- approvalId,
2281
- approvalKind: "fileChange",
2282
- changes: params?.fileChanges,
2283
- reason: firstString(params, ["reason"])
2284
- },
2285
- kind: "fileChange",
2286
- threadId,
2287
- turnId
2288
- };
2289
- case "item/permissions/requestApproval": return {
2290
- approvalId,
2291
- content: firstString(params, ["reason"]) ?? "Approve additional permissions",
2292
- details: {
2293
- approvalId,
2294
- approvalKind: "permissions",
2295
- cwd: firstString(params, ["cwd"]),
2296
- permissions: params?.permissions,
2297
- reason: firstString(params, ["reason"])
2298
- },
2299
- kind: "permissions",
2300
- threadId,
2301
- turnId
2302
- };
2303
- case "item/tool/requestUserInput": return {
2304
- approvalId,
2305
- content: "Input requested",
2306
- details: {
2307
- approvalId,
2308
- approvalKind: "structuredUserInput",
2309
- questions: Array.isArray(params?.questions) ? params.questions : []
2310
- },
2311
- kind: "structuredUserInput",
2312
- threadId,
2313
- turnId
2314
- };
2315
- case "mcpServer/elicitation/request": return {
2316
- approvalId,
2317
- content: firstString(params, ["message"]) ?? "MCP input requested",
2318
- details: {
2319
- approvalId,
2320
- approvalKind: "mcpElicitation",
2321
- message: firstString(params, ["message"]),
2322
- mode: firstString(params, ["mode"]),
2323
- serverName: firstString(params, ["serverName"]),
2324
- url: firstString(params, ["url"])
2325
- },
2326
- kind: "mcpElicitation",
2327
- threadId,
2328
- turnId
2329
- };
2330
- default: return;
2331
- }
2332
- }
2333
- async function resolveAppServerRequest(pending, decision, answers) {
2334
- switch (pending.kind) {
2335
- case "commandExecution":
2336
- await pending.appServer.respondToRequest(pending.requestId, { decision: pending.method === "execCommandApproval" ? legacyApprovalDecision(decision) : commandApprovalDecision(decision) });
2337
- return;
2338
- case "fileChange":
2339
- await pending.appServer.respondToRequest(pending.requestId, { decision: pending.method === "applyPatchApproval" ? legacyApprovalDecision(decision) : fileChangeApprovalDecision(decision) });
2340
- return;
2341
- case "permissions":
2342
- await pending.appServer.respondToRequest(pending.requestId, {
2343
- permissions: decision === "approve" || decision === "approve-for-session" ? {} : {},
2344
- scope: decision === "approve-for-session" ? "session" : "turn",
2345
- strictAutoReview: decision === "deny" || decision === "cancel"
2346
- });
2347
- return;
2348
- case "structuredUserInput":
2349
- await pending.appServer.respondToRequest(pending.requestId, { answers });
2350
- return;
2351
- case "mcpElicitation":
2352
- await pending.appServer.respondToRequest(pending.requestId, {
2353
- action: decision === "approve" || decision === "approve-for-session" ? "accept" : "decline",
2354
- content: answers.length > 0 ? { answers } : null,
2355
- _meta: null
2356
- });
2357
- return;
2358
- }
2359
- }
2360
- function legacyApprovalDecision(decision) {
2361
- switch (decision) {
2362
- case "approve": return "approved";
2363
- case "approve-for-session": return "approved_for_session";
2364
- case "cancel": return "abort";
2365
- default: return "denied";
2366
- }
2367
- }
2368
- function commandApprovalDecision(decision) {
2369
- switch (decision) {
2370
- case "approve": return "accept";
2371
- case "approve-for-session": return "acceptForSession";
2372
- case "cancel": return "cancel";
2373
- default: return "decline";
2374
- }
2375
- }
2376
- function fileChangeApprovalDecision(decision) {
2377
- switch (decision) {
2378
- case "approve": return "accept";
2379
- case "approve-for-session": return "acceptForSession";
2380
- case "cancel": return "cancel";
2381
- default: return "decline";
2382
- }
2383
- }
2384
- function kindFromProtocolType(type) {
2385
- switch (type) {
2386
- case "plan":
2387
- case "turn_plan_updated":
2388
- case "turn/plan/updated": return "plan";
2389
- case "request_user_input":
2390
- case "structured_user_input":
2391
- case "structuredUserInput": return "structuredUserInput";
2392
- case "approval_request":
2393
- case "approvalRequest": return "approvalRequest";
2394
- case "subagent_action":
2395
- case "subagentAction": return "subagentAction";
2396
- default: return;
2397
- }
2398
- }
2399
- function eventItem(event) {
2400
- if (!event || typeof event !== "object") return;
2401
- const record = event;
2402
- return record.item && typeof record.item === "object" ? record.item : record;
2403
- }
2404
- function recordParams(message) {
2405
- return message.params && typeof message.params === "object" ? message.params : void 0;
2406
- }
2407
- function turnIdFromParams(params) {
2408
- const turn = params?.turn;
2409
- return turn && typeof turn === "object" ? firstString(turn, ["id"]) : void 0;
2410
- }
2411
- function turnStatus(params) {
2412
- const turn = params?.turn;
2413
- if (turn && typeof turn === "object") {
2414
- const status = turn.status;
2415
- return typeof status === "string" ? status : void 0;
2416
- }
2417
- }
2418
- function turnErrorMessage(params) {
2419
- const turn = params?.turn;
2420
- if (!turn || typeof turn !== "object") return;
2421
- const error = turn.error;
2422
- return error && typeof error === "object" ? firstString(error, ["message"]) : void 0;
2423
- }
2424
- function planStepText(step) {
2425
- if (!step || typeof step !== "object") return;
2426
- const record = step;
2427
- const text = firstString(record, ["text", "step"]);
2428
- const status = firstString(record, ["status"]);
2429
- return text ? `${status ? `${status}: ` : ""}${text}` : void 0;
2430
- }
2431
- function firstString(record, keys) {
2432
- for (const key of keys) {
2433
- const value = record?.[key];
2434
- if (typeof value === "string" && value.trim()) return value;
2435
- }
2436
- }
2437
- function firstNumber(record, keys) {
2438
- for (const key of keys) {
2439
- const value = record?.[key];
2440
- if (typeof value === "number") return value;
2441
- }
2442
- }
2443
- function stringArray(value) {
2444
- if (!Array.isArray(value)) return [];
2445
- return value.filter((item) => typeof item === "string" && item.trim().length > 0);
2446
- }
2447
- function normalizeFileChanges(value) {
2448
- return value.flatMap((item) => {
2449
- if (!item || typeof item !== "object") return [];
2450
- const record = item;
2451
- const path = firstString(record, ["path"]);
2452
- const kind = firstString(record, ["kind", "type"]) ?? "modified";
2453
- return path ? [{
2454
- path,
2455
- kind
2456
- }] : [];
2457
- });
2458
- }
2459
- async function readWorkspaceChanges(workspacePath) {
2460
- const repo = await openRepository(workspacePath);
2461
- const [currentBranch, branches] = await Promise.all([currentGitBranch(workspacePath), listGitBranches(workspacePath)]);
2462
- const statusEntries = collectIterator(repo.statuses().iter()).filter((entry) => !entry.status().ignored);
2463
- const statusByPath = new Map(statusEntries.map((entry) => [entry.path(), entry]));
2464
- const status = statusEntries.map((entry) => formatStatusLine(entry.path(), entry.status())).join("\n");
2465
- const structuredDiff = createWorkspaceDiff(repo);
2466
- const diff = await git(workspacePath, [
2467
- "diff",
2468
- "--no-ext-diff",
2469
- "--no-color",
2470
- "HEAD",
2471
- "--"
2472
- ]).catch(() => structuredDiff.print());
2473
- const patchesByPath = splitDiffByPath(diff);
2474
- structuredDiff.findSimilar({ renames: true });
2475
- const stats = structuredDiff.stats();
2476
- const filesByPath = /* @__PURE__ */ new Map();
2477
- for (const delta of collectIterator(structuredDiff.deltas())) {
2478
- const path = delta.newFile().path() ?? delta.oldFile().path();
2479
- if (!path) continue;
2480
- const patch = patchesByPath.get(path) ?? patchesByPath.get(delta.oldFile().path() ?? "") ?? "";
2481
- const lineStats = countPatchLines(patch);
2482
- const statusEntry = statusByPath.get(path) ?? statusByPath.get(delta.oldFile().path() ?? "");
2483
- filesByPath.set(path, {
2484
- additions: lineStats.additions,
2485
- deletions: lineStats.deletions,
2486
- isBinary: delta.newFile().isBinary() || delta.oldFile().isBinary(),
2487
- oldPath: delta.oldFile().path(),
2488
- path,
2489
- patch,
2490
- stagedStatus: statusEntry?.headToIndex()?.status() ?? null,
2491
- status: delta.status(),
2492
- worktreeStatus: statusEntry?.indexToWorkdir()?.status() ?? null
2493
- });
2494
- }
2495
- for (const entry of statusEntries) {
2496
- if (filesByPath.has(entry.path())) continue;
2497
- filesByPath.set(entry.path(), {
2498
- additions: 0,
2499
- deletions: 0,
2500
- isBinary: false,
2501
- oldPath: null,
2502
- path: entry.path(),
2503
- patch: "",
2504
- stagedStatus: entry.headToIndex()?.status() ?? null,
2505
- status: statusNameFromStatus(entry.status()),
2506
- worktreeStatus: entry.indexToWorkdir()?.status() ?? null
2507
- });
2508
- }
2509
- const files = [...filesByPath.values()].sort((left, right) => left.path.localeCompare(right.path));
2510
- const fileStats = {
2511
- additions: files.reduce((total, file) => total + file.additions, 0),
2512
- deletions: files.reduce((total, file) => total + file.deletions, 0),
2513
- filesChanged: files.length
2514
- };
2515
- return {
2516
- branches,
2517
- currentBranch,
2518
- diff,
2519
- files,
2520
- hasChanges: files.length > 0 || Boolean(status.trim() || diff.trim()),
2521
- status,
2522
- stats: files.length > 0 ? fileStats : {
2523
- additions: Number(stats.insertions),
2524
- deletions: Number(stats.deletions),
2525
- filesChanged: Number(stats.filesChanged)
2526
- }
2527
- };
2528
- }
2529
- async function currentGitBranch(workspacePath) {
2530
- return (await git(workspacePath, ["branch", "--show-current"]).catch(() => "")).trim() || null;
2531
- }
2532
- async function localGitBranchExists(workspacePath, branch) {
2533
- try {
2534
- await git(workspacePath, [
2535
- "show-ref",
2536
- "--verify",
2537
- "--quiet",
2538
- `refs/heads/${branch}`
2539
- ]);
2540
- return true;
2541
- } catch {
2542
- return false;
2543
- }
2544
- }
2545
- async function listGitBranches(workspacePath) {
2546
- return (await git(workspacePath, [
2547
- "branch",
2548
- "--format=%(HEAD)%09%(refname:short)",
2549
- "--sort=refname"
2550
- ]).catch(() => "")).split("\n").map((line) => {
2551
- const [headMarker, name] = line.split(" ");
2552
- return {
2553
- current: headMarker === "*",
2554
- name: name?.trim() ?? ""
2555
- };
2556
- }).filter((branch) => branch.name.length > 0);
2557
- }
2558
- function createWorkspaceDiff(repo) {
2559
- const options = {
2560
- includeUntracked: true,
2561
- recurseUntrackedDirs: true,
2562
- showUntrackedContent: true
2563
- };
2564
- try {
2565
- return repo.diffTreeToWorkdirWithIndex(repo.head().peelToTree(), options);
2566
- } catch {
2567
- return repo.diffIndexToWorkdir(void 0, options);
2568
- }
2569
- }
2570
- function collectIterator(iterator) {
2571
- const items = [];
2572
- for (let result = iterator.next(); !result.done; result = iterator.next()) items.push(result.value);
2573
- return items;
2574
- }
2575
- function splitDiffByPath(diff) {
2576
- const patches = /* @__PURE__ */ new Map();
2577
- const sections = diff.split(/(?=^diff --git )/m).filter(Boolean);
2578
- for (const section of sections) {
2579
- const header = section.match(/^diff --git a\/(.+) b\/(.+)$/m);
2580
- if (!header) continue;
2581
- const oldPath = header[1];
2582
- const newPath = header[2];
2583
- patches.set(newPath, section.trimEnd());
2584
- patches.set(oldPath, section.trimEnd());
2585
- }
2586
- return patches;
2587
- }
2588
- function countPatchLines(patch) {
2589
- let additions = 0;
2590
- let deletions = 0;
2591
- for (const line of patch.split("\n")) {
2592
- if (line.startsWith("+++") || line.startsWith("---")) continue;
2593
- if (line.startsWith("+")) additions += 1;
2594
- else if (line.startsWith("-")) deletions += 1;
2595
- }
2596
- return {
2597
- additions,
2598
- deletions
2599
- };
2600
- }
2601
- function formatStatusLine(path, status) {
2602
- if (status.ignored) return `!! ${path}`;
2603
- if (status.wtNew && !status.indexNew) return `?? ${path}`;
2604
- return `${statusIndexCode(status)}${statusWorktreeCode(status)} ${path}`;
2605
- }
2606
- function statusIndexCode(status) {
2607
- if (status.conflicted) return "U";
2608
- if (status.indexRenamed) return "R";
2609
- if (status.indexNew) return "A";
2610
- if (status.indexDeleted) return "D";
2611
- if (status.indexTypechange) return "T";
2612
- return status.indexModified ? "M" : " ";
2613
- }
2614
- function statusWorktreeCode(status) {
2615
- if (status.conflicted) return "U";
2616
- if (status.wtRenamed) return "R";
2617
- if (status.wtDeleted) return "D";
2618
- if (status.wtTypechange) return "T";
2619
- if (status.wtUnreadable) return "?";
2620
- return status.wtModified ? "M" : " ";
2621
- }
2622
- function statusNameFromStatus(status) {
2623
- if (status.conflicted) return "Conflicted";
2624
- if (status.indexNew || status.wtNew) return "Added";
2625
- if (status.indexDeleted || status.wtDeleted) return "Deleted";
2626
- if (status.indexRenamed || status.wtRenamed) return "Renamed";
2627
- if (status.indexTypechange || status.wtTypechange) return "Typechange";
2628
- if (status.ignored) return "Ignored";
2629
- return status.current ? "Unmodified" : "Modified";
2630
- }
2631
- async function git(cwd, args) {
2632
- const { stdout } = await execFileAsync("git", args, {
2633
- cwd,
2634
- maxBuffer: 16 * 1024 * 1024
2635
- });
2636
- return stdout.trimEnd();
2637
- }
2638
- function validationError(error) {
2639
- return apiError("invalid_request", "Request body did not match the Codex Relay API schema.", error.issues.map((issue) => `${issue.path.join(".") || "body"}: ${issue.message}`));
2640
- }
2641
- function apiError(code, message, issues) {
2642
- return { error: {
2643
- code,
2644
- message,
2645
- issues
2646
- } };
2647
- }
2648
- function errorMessage(error) {
2649
- return error instanceof Error ? error.message : "Codex run failed.";
2650
- }
2651
- //#endregion
2652
- //#region src/pairing-store.ts
2653
- async function createTursoPairingSessionStore(path) {
2654
- if (path !== ":memory:") await mkdir(dirname(path), { recursive: true });
2655
- const db = await connect(path);
2656
- await db.exec(`
2657
- CREATE TABLE IF NOT EXISTS pairing_sessions (
2658
- token_hash TEXT PRIMARY KEY,
2659
- client_session_id TEXT,
2660
- client_name TEXT,
2661
- expires_at INTEGER NOT NULL,
2662
- key_epoch INTEGER,
2663
- mobile_to_server_key TEXT,
2664
- server_to_mobile_key TEXT,
2665
- last_mobile_counter INTEGER,
2666
- next_server_counter INTEGER,
2667
- created_at INTEGER NOT NULL,
2668
- updated_at INTEGER NOT NULL
2669
- );
2670
-
2671
- CREATE TABLE IF NOT EXISTS pending_pairings (
2672
- approval_code TEXT PRIMARY KEY,
2673
- client_session_id TEXT,
2674
- client_name TEXT,
2675
- client_ephemeral_public_key TEXT NOT NULL,
2676
- client_nonce TEXT NOT NULL,
2677
- server_url TEXT NOT NULL,
2678
- approved INTEGER NOT NULL DEFAULT 0,
2679
- expires_at INTEGER NOT NULL,
2680
- created_at INTEGER NOT NULL,
2681
- updated_at INTEGER NOT NULL
2682
- );
2683
- `);
2684
- await ensurePairingSessionColumns();
2685
- async function countActive(now) {
2686
- const row = await db.prepare("SELECT COUNT(DISTINCT COALESCE(client_session_id, token_hash)) AS count FROM pairing_sessions WHERE expires_at > ?").get(now);
2687
- return Number(row?.count ?? 0);
2688
- }
2689
- async function deleteSession(tokenHash) {
2690
- await db.prepare("DELETE FROM pairing_sessions WHERE token_hash = ?").run(tokenHash);
2691
- }
2692
- async function deletePendingPairing(approvalCode) {
2693
- await db.prepare("DELETE FROM pending_pairings WHERE approval_code = ?").run(approvalCode);
2694
- }
2695
- async function getPendingPairing(approvalCode, now) {
2696
- const row = await db.prepare(`SELECT approval_code AS approvalCode,
2697
- client_session_id AS clientSessionId,
2698
- client_name AS clientName,
2699
- client_ephemeral_public_key AS clientEphemeralPublicKey,
2700
- client_nonce AS clientNonce,
2701
- server_url AS serverUrl,
2702
- approved,
2703
- expires_at AS expiresAt
2704
- FROM pending_pairings
2705
- WHERE approval_code = ?`).get(approvalCode);
2706
- if (!row) return;
2707
- const expiresAt = Number(row.expiresAt);
2708
- if (now > expiresAt) {
2709
- await deletePendingPairing(approvalCode);
2710
- return;
2711
- }
2712
- return {
2713
- approvalCode: String(row.approvalCode),
2714
- approved: Number(row.approved) === 1,
2715
- clientEphemeralPublicKey: String(row.clientEphemeralPublicKey),
2716
- clientSessionId: typeof row.clientSessionId === "string" ? row.clientSessionId : void 0,
2717
- clientName: typeof row.clientName === "string" ? row.clientName : void 0,
2718
- clientNonce: String(row.clientNonce),
2719
- expiresAt,
2720
- serverUrl: String(row.serverUrl)
2721
- };
2722
- }
2723
- return {
2724
- async approvePendingPairing(approvalCode, now) {
2725
- const pending = await getPendingPairing(approvalCode, now);
2726
- if (!pending) return;
2727
- await db.prepare("UPDATE pending_pairings SET approved = 1, updated_at = ? WHERE approval_code = ?").run(now, approvalCode);
2728
- return {
2729
- ...pending,
2730
- approved: true
2731
- };
2732
- },
2733
- countActive,
2734
- async createPendingPairing(pairing) {
2735
- const now = Date.now();
2736
- await db.prepare(`INSERT INTO pending_pairings (
2737
- approval_code,
2738
- client_session_id,
2739
- client_name,
2740
- client_ephemeral_public_key,
2741
- client_nonce,
2742
- server_url,
2743
- approved,
2744
- expires_at,
2745
- created_at,
2746
- updated_at
2747
- )
2748
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(pairing.approvalCode, pairing.clientSessionId ?? null, pairing.clientName ?? null, pairing.clientEphemeralPublicKey, pairing.clientNonce, pairing.serverUrl, pairing.approved ? 1 : 0, pairing.expiresAt, now, now);
2749
- },
2750
- async createSession(tokenHash, session) {
2751
- const now = Date.now();
2752
- const secure = encodeSecureSession(session.secureSession);
2753
- if (session.clientSessionId) {
2754
- await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id = ?").run(session.clientSessionId);
2755
- if (session.clientName) await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id IS NULL AND client_name = ?").run(session.clientName);
2756
- }
2757
- await db.prepare(`INSERT INTO pairing_sessions (
2758
- token_hash,
2759
- client_session_id,
2760
- client_name,
2761
- expires_at,
2762
- key_epoch,
2763
- mobile_to_server_key,
2764
- server_to_mobile_key,
2765
- last_mobile_counter,
2766
- next_server_counter,
2767
- created_at,
2768
- updated_at
2769
- )
2770
- 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);
2771
- return countActive(now);
2772
- },
2773
- deleteSession,
2774
- deletePendingPairing,
2775
- getPendingPairing,
2776
- async getValidSession(tokenHash, now) {
2777
- const row = await db.prepare(`SELECT client_name AS clientName,
2778
- client_session_id AS clientSessionId,
2779
- expires_at AS expiresAt,
2780
- key_epoch AS keyEpoch,
2781
- mobile_to_server_key AS mobileToServerKey,
2782
- server_to_mobile_key AS serverToMobileKey,
2783
- last_mobile_counter AS lastMobileCounter,
2784
- next_server_counter AS nextServerCounter
2785
- FROM pairing_sessions
2786
- WHERE token_hash = ?`).get(tokenHash);
2787
- if (!row) return;
2788
- const expiresAt = Number(row.expiresAt);
2789
- if (now > expiresAt) {
2790
- await deleteSession(tokenHash);
2791
- return;
2792
- }
2793
- return {
2794
- clientSessionId: typeof row.clientSessionId === "string" ? row.clientSessionId : void 0,
2795
- clientName: typeof row.clientName === "string" ? row.clientName : void 0,
2796
- expiresAt,
2797
- secureSession: decodeSecureSession(row)
2798
- };
2799
- },
2800
- async pruneExpired(now) {
2801
- await db.prepare("DELETE FROM pairing_sessions WHERE expires_at <= ?").run(now);
2802
- await db.prepare("DELETE FROM pending_pairings WHERE expires_at <= ?").run(now);
2803
- },
2804
- async rotateSession(oldTokenHash, newTokenHash, session) {
2805
- const now = Date.now();
2806
- const secure = encodeSecureSession(session.secureSession);
2807
- await db.transaction(async () => {
2808
- await db.prepare("DELETE FROM pairing_sessions WHERE token_hash = ?").run(oldTokenHash);
2809
- if (session.clientSessionId) {
2810
- await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id = ?").run(session.clientSessionId);
2811
- if (session.clientName) await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id IS NULL AND client_name = ?").run(session.clientName);
2812
- }
2813
- await db.prepare(`INSERT INTO pairing_sessions (
2814
- token_hash,
2815
- client_session_id,
2816
- client_name,
2817
- expires_at,
2818
- key_epoch,
2819
- mobile_to_server_key,
2820
- server_to_mobile_key,
2821
- last_mobile_counter,
2822
- next_server_counter,
2823
- created_at,
2824
- updated_at
2825
- )
2826
- 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);
2827
- })();
2828
- return countActive(now);
2829
- },
2830
- async updateSecureSession(tokenHash, secureSession) {
2831
- const secure = encodeSecureSession(secureSession);
2832
- const now = Date.now();
2833
- await db.prepare(`UPDATE pairing_sessions
2834
- SET key_epoch = ?,
2835
- mobile_to_server_key = ?,
2836
- server_to_mobile_key = ?,
2837
- last_mobile_counter = ?,
2838
- next_server_counter = ?,
2839
- updated_at = ?
2840
- WHERE token_hash = ?`).run(secure.keyEpoch, secure.mobileToServerKey, secure.serverToMobileKey, secure.lastMobileCounter, secure.nextServerCounter, now, tokenHash);
2841
- }
2842
- };
2843
- async function ensurePairingSessionColumns() {
2844
- const rows = await db.prepare("PRAGMA table_info(pairing_sessions)").all();
2845
- const columns = new Set(resultRows(rows).map((row) => String(row.name)));
2846
- for (const [column, sql] of [
2847
- ["client_session_id", "ALTER TABLE pairing_sessions ADD COLUMN client_session_id TEXT"],
2848
- ["key_epoch", "ALTER TABLE pairing_sessions ADD COLUMN key_epoch INTEGER"],
2849
- ["mobile_to_server_key", "ALTER TABLE pairing_sessions ADD COLUMN mobile_to_server_key TEXT"],
2850
- ["server_to_mobile_key", "ALTER TABLE pairing_sessions ADD COLUMN server_to_mobile_key TEXT"],
2851
- ["last_mobile_counter", "ALTER TABLE pairing_sessions ADD COLUMN last_mobile_counter INTEGER"],
2852
- ["next_server_counter", "ALTER TABLE pairing_sessions ADD COLUMN next_server_counter INTEGER"]
2853
- ]) if (!columns.has(column)) await db.exec(sql);
2854
- const pendingRows = await db.prepare("PRAGMA table_info(pending_pairings)").all();
2855
- 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");
2856
- }
2857
- }
2858
- function encodeSecureSession(session) {
2859
- if (!session) return;
2860
- return {
2861
- keyEpoch: session.keyEpoch,
2862
- lastMobileCounter: session.lastMobileCounter,
2863
- mobileToServerKey: fromByteArray(session.mobileToServerKey),
2864
- nextServerCounter: session.nextServerCounter,
2865
- serverToMobileKey: fromByteArray(session.serverToMobileKey)
2866
- };
2867
- }
2868
- function decodeSecureSession(row) {
2869
- if (typeof row.mobileToServerKey !== "string" || typeof row.serverToMobileKey !== "string" || row.keyEpoch === null || row.lastMobileCounter === null || row.nextServerCounter === null) return;
2870
- return {
2871
- keyEpoch: Number(row.keyEpoch),
2872
- lastMobileCounter: Number(row.lastMobileCounter),
2873
- mobileToServerKey: toByteArray(row.mobileToServerKey),
2874
- nextServerCounter: Number(row.nextServerCounter),
2875
- serverToMobileKey: toByteArray(row.serverToMobileKey)
2876
- };
2877
- }
2878
- function resultRows(result) {
2879
- if (Array.isArray(result)) return result;
2880
- if (result && typeof result === "object" && Array.isArray(result.rows)) return result.rows;
2881
- return [];
2882
- }
2883
- //#endregion
2884
- //#region src/index.ts
2885
- const port = Number(process.env.PORT ?? 8787);
2886
- const hostname$1 = process.env.HOST ?? "0.0.0.0";
2887
- const clientTokenTtlMs = 10080 * 60 * 1e3;
2888
- const serverIdentity = createServerIdentity();
2889
- const approvalSecret = await getApprovalSecret();
2890
- const colors = pc.createColors(!process.env.NO_COLOR && process.env.TERM !== "dumb");
2891
- const color = {
2892
- brand: colors.cyan,
2893
- code: colors.yellow,
2894
- command: colors.green,
2895
- event: colors.magenta,
2896
- muted: colors.gray,
2897
- prompt: colors.cyan,
2898
- url: colors.blue
2899
- };
2900
- serve({
2901
- fetch: createApp({ pairing: {
2902
- approvalSecret,
2903
- serverIdentity,
2904
- createClientToken: () => randomBytes(32).toString("base64url"),
2905
- hashClientToken,
2906
- sessions: await createTursoPairingSessionStore(process.env.CODEX_RELAY_AUTH_DB_PATH ?? resolve(process.cwd(), ".codex-relay/auth.db")),
2907
- tokenTtlMs: clientTokenTtlMs,
2908
- onPaired: ({ clientName, tokenCount }) => {
2909
- logRuntimeEvent("Paired", `Mobile client connected${clientName ? ` from ${clientName}` : ""}; ${formatClientCount(tokenCount)} active.`);
2910
- },
2911
- onPairAttempt: ({ remoteAddress }) => {
2912
- logRuntimeEvent("Pairing", `Handshake received${remoteAddress ? ` from ${remoteAddress}` : ""}.`);
2913
- },
2914
- onPairApprovalRequested: ({ clientName }) => {
2915
- logRuntimeEvent("Approval", `Pairing approval requested${clientName ? ` from ${clientName}` : ""}. Use the code shown in the mobile app to approve locally.`);
2916
- },
2917
- onPairApproved: ({ clientName }) => {
2918
- logRuntimeEvent("Approved", `Pairing request approved${clientName ? ` for ${clientName}` : ""}. Waiting for secure session pickup.`);
2919
- },
2920
- onTokenRefreshed: ({ clientName, tokenCount }) => {
2921
- logRuntimeEvent("Refreshed", `Mobile session rotated${clientName ? ` for ${clientName}` : ""}; ${formatClientCount(tokenCount)} active.`);
2922
- }
2923
- } }).fetch,
2924
- hostname: hostname$1,
2925
- port
2926
- }, (info) => {
2927
- const listenUrl = `http://${info.address}:${info.port}`;
2928
- const connectUrl = getConfiguredConnectUrl() ?? getTailscaleConnectUrl(info.port) ?? getLocalNetworkConnectUrl(info.port) ?? listenUrl;
2929
- const pairingPayload = createPairingQrPayload(connectUrl);
2930
- writeServerState({
2931
- connectUrl,
2932
- host: hostname$1,
2933
- listenUrl,
2934
- pairingPayload,
2935
- port: info.port
2936
- });
2937
- writeBackgroundPid();
2938
- console.log("");
2939
- qrcode.generate(pairingPayload, { small: true });
2940
- console.log(formatStartupInstructions({
2941
- connectUrl,
2942
- listenUrl,
2943
- pairingPayload,
2944
- port: info.port
2945
- }));
2946
- });
2947
- function formatStartupInstructions(details) {
2948
- return [
2949
- "",
2950
- ...[
2951
- `${color.prompt("›")} Scan the QR code above to pair ${color.brand("Codex Relay mobile")}.`,
2952
- "",
2953
- `${color.prompt("›")} Mobile: ${color.url(details.connectUrl)}`,
2954
- `${color.prompt("›")} Server: ${color.muted(details.listenUrl)}`,
2955
- "",
2956
- `${color.prompt("›")} Pairing: ${color.url(details.pairingPayload)}`,
2957
- "",
2958
- `${color.prompt("›")} Waiting for pairing requests`,
2959
- `${color.prompt("›")} Approve a device with ${color.command(formatApprovalCommand("<code>", details.port))}`
2960
- ],
2961
- ""
2962
- ].join("\n");
2963
- }
2964
- function logRuntimeEvent(label, message) {
2965
- console.log(`${color.prompt("›")} ${color.event(label.padEnd(8))} ${message}`);
2966
- }
2967
- function formatClientCount(tokenCount) {
2968
- return `${tokenCount} client${tokenCount === 1 ? "" : "s"}`;
2969
- }
2970
- function createPairingQrPayload(serverUrl) {
2971
- const url = new URL("codex-relay://pair");
2972
- url.searchParams.set("serverUrl", serverUrl);
2973
- url.searchParams.set("serverPublicKey", serverIdentity.publicKey);
2974
- return url.toString();
2975
- }
2976
- function hashClientToken(token) {
2977
- return createHash("sha256").update(token).digest("base64url");
2978
- }
2979
- function getConfiguredConnectUrl() {
2980
- const configuredUrl = normalizeUrl(process.env.CODEX_RELAY_PUBLIC_URL);
2981
- if (configuredUrl) return configuredUrl;
2982
- }
2983
- function getTailscaleConnectUrl(port) {
2984
- const status = getTailscaleStatus();
2985
- const tailscaleIp = status?.Self?.TailscaleIPs?.find((ip) => ip.startsWith("100.") && ip.includes("."));
2986
- if (tailscaleIp) return `http://${tailscaleIp}:${port}`;
2987
- const dnsName = status?.Self?.DNSName?.replace(/\.$/, "");
2988
- if (dnsName) return getTailscaleServeHttpsUrl(dnsName, port) ?? `http://${dnsName}:${port}`;
2989
- const tailscaleHost = status?.Self?.TailscaleIPs?.find((ip) => ip.includes("."));
2990
- return tailscaleHost ? `http://${tailscaleHost}:${port}` : void 0;
2991
- }
2992
- async function getApprovalSecret() {
2993
- if (process.env.CODEX_RELAY_APPROVAL_SECRET) return process.env.CODEX_RELAY_APPROVAL_SECRET;
2994
- const path = resolve(process.cwd(), ".codex-relay/approval-secret");
2995
- try {
2996
- return (await readFile(path, "utf8")).trim();
2997
- } catch {
2998
- const secret = randomBytes(32).toString("base64url");
2999
- await mkdir(dirname(path), { recursive: true });
3000
- await writeFile(path, `${secret}\n`, { mode: 384 });
3001
- return secret;
3002
- }
3003
- }
3004
- async function writeServerState(details) {
3005
- const path = resolve(process.cwd(), ".codex-relay/server-state.json");
3006
- await mkdir(dirname(path), { recursive: true });
3007
- await writeFile(path, `${JSON.stringify(details)}\n`, { mode: 384 });
3008
- }
3009
- async function writeBackgroundPid() {
3010
- const path = process.env.CODEX_RELAY_PID_PATH;
3011
- if (!path) return;
3012
- await mkdir(dirname(path), { recursive: true });
3013
- await writeFile(path, `${process.pid}\n`, { mode: 384 });
3014
- }
3015
- function formatApprovalCommand(approvalCode, activePort) {
3016
- return activePort === 8787 ? `npx codex-relay@latest approve ${approvalCode}` : `PORT=${activePort} npx codex-relay@latest approve ${approvalCode}`;
3017
- }
3018
- function getLocalNetworkConnectUrl(port) {
3019
- for (const addresses of Object.values(networkInterfaces())) for (const address of addresses ?? []) if (address.family === "IPv4" && !address.internal) return `http://${address.address}:${port}`;
3020
- }
3021
- function normalizeUrl(value) {
3022
- if (!value) return;
3023
- const trimmed = value.trim().replace(/\/$/, "");
3024
- if (!trimmed) return;
3025
- try {
3026
- const url = new URL(trimmed);
3027
- return url.protocol === "http:" || url.protocol === "https:" ? url.toString().replace(/\/$/, "") : void 0;
3028
- } catch {
3029
- return;
3030
- }
3031
- }
3032
- function getTailscaleStatus() {
3033
- try {
3034
- const output = execFileSync("tailscale", ["status", "--json"], {
3035
- encoding: "utf8",
3036
- stdio: [
3037
- "ignore",
3038
- "pipe",
3039
- "ignore"
3040
- ],
3041
- timeout: 1500
3042
- });
3043
- return JSON.parse(output);
3044
- } catch {
3045
- return;
3046
- }
3047
- }
3048
- function getTailscaleServeHttpsUrl(dnsName, port) {
3049
- try {
3050
- const output = execFileSync("tailscale", [
3051
- "serve",
3052
- "status",
3053
- "--json"
3054
- ], {
3055
- encoding: "utf8",
3056
- stdio: [
3057
- "ignore",
3058
- "pipe",
3059
- "ignore"
3060
- ],
3061
- timeout: 1500
3062
- });
3063
- const serveStatus = JSON.parse(output);
3064
- const portKey = String(port);
3065
- const hostPort = `${dnsName}:${portKey}`;
3066
- return serveStatus.TCP?.[portKey]?.HTTPS && serveStatus.Web?.[hostPort] ? `https://${hostPort}` : void 0;
3067
- } catch {
3068
- return;
3069
- }
3070
- }
3071
- //#endregion
3072
- export {};