codex-blocker 0.1.1 → 0.1.3

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.
@@ -0,0 +1,1140 @@
1
+ // src/server.ts
2
+ import { createServer } from "http";
3
+ import { WebSocketServer, WebSocket } from "ws";
4
+
5
+ // src/types.ts
6
+ var DEFAULT_PORT = 8765;
7
+ var SESSION_TIMEOUT_MS = 5 * 60 * 1e3;
8
+ var CODEX_SESSIONS_SCAN_INTERVAL_MS = 2e3;
9
+ var MOBILE_PAIRING_TTL_MS = 2 * 60 * 1e3;
10
+ var MOBILE_QR_PAIRING_TTL_MS = 60 * 1e3;
11
+
12
+ // src/state.ts
13
+ var SessionState = class {
14
+ sessions = /* @__PURE__ */ new Map();
15
+ listeners = /* @__PURE__ */ new Set();
16
+ cleanupInterval = null;
17
+ constructor() {
18
+ this.cleanupInterval = setInterval(() => {
19
+ this.cleanupStaleSessions();
20
+ }, 3e4);
21
+ }
22
+ subscribe(callback) {
23
+ this.listeners.add(callback);
24
+ callback(this.getStateMessage());
25
+ return () => this.listeners.delete(callback);
26
+ }
27
+ broadcast() {
28
+ const message = this.getStateMessage();
29
+ for (const listener of this.listeners) {
30
+ listener(message);
31
+ }
32
+ }
33
+ getStateMessage() {
34
+ const sessions = Array.from(this.sessions.values());
35
+ const working = sessions.filter((s) => s.status === "working").length;
36
+ const waitingForInput = sessions.filter(
37
+ (s) => s.status === "waiting_for_input"
38
+ ).length;
39
+ return {
40
+ type: "state",
41
+ blocked: waitingForInput > 0 || working === 0,
42
+ sessions: sessions.length,
43
+ working,
44
+ waitingForInput
45
+ };
46
+ }
47
+ handleCodexActivity(activity) {
48
+ this.ensureSession(activity.sessionId, activity.cwd);
49
+ const session = this.sessions.get(activity.sessionId);
50
+ const statusChanged = session.status !== "working";
51
+ session.status = "working";
52
+ session.waitingForInputSince = void 0;
53
+ session.lastActivity = /* @__PURE__ */ new Date();
54
+ session.lastSeen = /* @__PURE__ */ new Date();
55
+ session.idleTimeoutMs = activity.idleTimeoutMs;
56
+ if (statusChanged) {
57
+ this.broadcast();
58
+ }
59
+ }
60
+ setCodexIdle(sessionId, cwd) {
61
+ this.ensureSession(sessionId, cwd);
62
+ const session = this.sessions.get(sessionId);
63
+ if (session.status !== "idle") {
64
+ session.status = "idle";
65
+ session.waitingForInputSince = void 0;
66
+ session.lastActivity = /* @__PURE__ */ new Date();
67
+ this.broadcast();
68
+ }
69
+ }
70
+ setWaitingForInput(sessionId, cwd) {
71
+ this.ensureSession(sessionId, cwd);
72
+ const session = this.sessions.get(sessionId);
73
+ const statusChanged = session.status !== "waiting_for_input";
74
+ session.status = "waiting_for_input";
75
+ session.waitingForInputSince ??= /* @__PURE__ */ new Date();
76
+ session.lastActivity = /* @__PURE__ */ new Date();
77
+ session.lastSeen = /* @__PURE__ */ new Date();
78
+ if (statusChanged) {
79
+ this.broadcast();
80
+ }
81
+ }
82
+ markCodexSessionSeen(sessionId, cwd) {
83
+ const created = this.ensureSession(sessionId, cwd);
84
+ const session = this.sessions.get(sessionId);
85
+ session.lastSeen = /* @__PURE__ */ new Date();
86
+ if (created) {
87
+ this.broadcast();
88
+ }
89
+ }
90
+ removeSession(sessionId) {
91
+ if (this.sessions.delete(sessionId)) {
92
+ this.broadcast();
93
+ }
94
+ }
95
+ ensureSession(sessionId, cwd) {
96
+ if (!this.sessions.has(sessionId)) {
97
+ this.sessions.set(sessionId, {
98
+ id: sessionId,
99
+ status: "idle",
100
+ lastActivity: /* @__PURE__ */ new Date(),
101
+ lastSeen: /* @__PURE__ */ new Date(),
102
+ cwd
103
+ });
104
+ console.log("Codex session connected");
105
+ return true;
106
+ } else if (cwd) {
107
+ const session = this.sessions.get(sessionId);
108
+ session.cwd = cwd;
109
+ }
110
+ return false;
111
+ }
112
+ cleanupStaleSessions() {
113
+ const now = Date.now();
114
+ let removed = 0;
115
+ let changed = 0;
116
+ for (const [id, session] of this.sessions) {
117
+ if (now - session.lastSeen.getTime() > SESSION_TIMEOUT_MS) {
118
+ this.sessions.delete(id);
119
+ removed++;
120
+ continue;
121
+ }
122
+ if (session.status === "working" && session.idleTimeoutMs && now - session.lastActivity.getTime() > session.idleTimeoutMs) {
123
+ session.status = "idle";
124
+ session.waitingForInputSince = void 0;
125
+ changed++;
126
+ }
127
+ }
128
+ if (removed > 0 || changed > 0) {
129
+ this.broadcast();
130
+ }
131
+ }
132
+ getStatus() {
133
+ const sessions = Array.from(this.sessions.values());
134
+ const working = sessions.filter((s) => s.status === "working").length;
135
+ const waitingForInput = sessions.filter(
136
+ (s) => s.status === "waiting_for_input"
137
+ ).length;
138
+ return {
139
+ blocked: waitingForInput > 0 || working === 0,
140
+ sessions: sessions.length,
141
+ working,
142
+ waitingForInput
143
+ };
144
+ }
145
+ destroy() {
146
+ if (this.cleanupInterval) {
147
+ clearInterval(this.cleanupInterval);
148
+ }
149
+ this.sessions.clear();
150
+ this.listeners.clear();
151
+ }
152
+ };
153
+ var state = new SessionState();
154
+
155
+ // src/codex.ts
156
+ import { existsSync, createReadStream, promises as fs } from "fs";
157
+ import { homedir } from "os";
158
+ import { join } from "path";
159
+
160
+ // src/codex-parse.ts
161
+ import { basename, dirname } from "path";
162
+ function isRolloutFile(filePath) {
163
+ const name = basename(filePath);
164
+ return name === "rollout.jsonl" || /^rollout-.+\.jsonl$/.test(name);
165
+ }
166
+ function sessionIdFromPath(filePath) {
167
+ const name = basename(filePath);
168
+ const match = name.match(/^rollout-(.+)\.jsonl$/);
169
+ if (match) return match[1];
170
+ if (name === "rollout.jsonl") {
171
+ const parent = basename(dirname(filePath));
172
+ if (parent !== "sessions") return parent;
173
+ }
174
+ return filePath;
175
+ }
176
+ function findFirstStringValue(obj, keys, maxDepth = 6) {
177
+ if (!obj || typeof obj !== "object") return void 0;
178
+ const queue = [{ value: obj, depth: 0 }];
179
+ while (queue.length) {
180
+ const current = queue.shift();
181
+ if (!current) break;
182
+ const { value, depth } = current;
183
+ if (!value || typeof value !== "object") continue;
184
+ const record = value;
185
+ for (const key of keys) {
186
+ const candidate = record[key];
187
+ if (typeof candidate === "string" && candidate.length > 0) {
188
+ return candidate;
189
+ }
190
+ }
191
+ if (depth >= maxDepth) continue;
192
+ for (const child of Object.values(record)) {
193
+ if (child && typeof child === "object") {
194
+ queue.push({ value: child, depth: depth + 1 });
195
+ }
196
+ }
197
+ }
198
+ return void 0;
199
+ }
200
+ function parseCodexLine(line, sessionId) {
201
+ let currentSessionId = sessionId;
202
+ let previousSessionId;
203
+ let cwd;
204
+ let markWorking = false;
205
+ let markActivity = false;
206
+ let markWaitingForInput = false;
207
+ let markIdle = false;
208
+ let markLegacyIdleCandidate = false;
209
+ let assistantMessagePhase;
210
+ try {
211
+ const payload = JSON.parse(line);
212
+ const entryType = typeof payload.type === "string" ? payload.type : void 0;
213
+ const innerPayload = payload.payload;
214
+ const innerType = innerPayload && typeof innerPayload === "object" ? innerPayload.type : void 0;
215
+ if (entryType === "session_meta") {
216
+ const metaId = innerPayload && typeof innerPayload === "object" ? innerPayload.id : void 0;
217
+ if (typeof metaId === "string" && metaId.length > 0 && metaId !== currentSessionId) {
218
+ previousSessionId = currentSessionId;
219
+ currentSessionId = metaId;
220
+ }
221
+ }
222
+ cwd = findFirstStringValue(innerPayload, ["cwd"]) ?? findFirstStringValue(payload, ["cwd"]);
223
+ const innerTypeString = typeof innerType === "string" ? innerType : void 0;
224
+ if (entryType === "event_msg" && innerTypeString === "user_message") {
225
+ markWorking = true;
226
+ }
227
+ if (entryType === "event_msg" && innerTypeString === "agent_message") {
228
+ markLegacyIdleCandidate = true;
229
+ }
230
+ if (entryType === "event_msg" && innerTypeString === "agent_reasoning") {
231
+ markActivity = true;
232
+ }
233
+ if (entryType === "event_msg" && innerTypeString === "item_completed") {
234
+ markIdle = true;
235
+ }
236
+ if (entryType === "response_item" && innerTypeString === "message") {
237
+ const role = innerPayload && typeof innerPayload === "object" ? innerPayload.role : void 0;
238
+ if (role === "user") {
239
+ const messageText = extractMessageText(innerPayload);
240
+ if (!messageText || !messageText.trim().startsWith("<environment_context>")) {
241
+ markWorking = true;
242
+ }
243
+ }
244
+ if (role === "assistant" && innerPayload && typeof innerPayload === "object") {
245
+ const phase = innerPayload.phase;
246
+ if (typeof phase === "string" && phase.length > 0) {
247
+ assistantMessagePhase = phase;
248
+ if (phase === "final_answer") {
249
+ markIdle = true;
250
+ } else if (phase === "commentary") {
251
+ markActivity = true;
252
+ }
253
+ }
254
+ }
255
+ }
256
+ if (entryType === "response_item" && innerTypeString === "function_call") {
257
+ const callName = innerPayload && typeof innerPayload === "object" ? innerPayload.name : void 0;
258
+ if (callName === "request_user_input") {
259
+ markWaitingForInput = true;
260
+ } else {
261
+ markActivity = true;
262
+ }
263
+ }
264
+ if (entryType === "response_item" && (innerTypeString === "reasoning" || innerTypeString === "function_call_output" || innerTypeString === "custom_tool_call" || innerTypeString === "custom_tool_call_output")) {
265
+ markActivity = true;
266
+ }
267
+ } catch {
268
+ }
269
+ return {
270
+ sessionId: currentSessionId,
271
+ previousSessionId,
272
+ cwd,
273
+ markWorking,
274
+ markActivity,
275
+ markWaitingForInput,
276
+ markIdle,
277
+ markLegacyIdleCandidate,
278
+ assistantMessagePhase
279
+ };
280
+ }
281
+ function extractMessageText(payload) {
282
+ if (!payload || typeof payload !== "object") return void 0;
283
+ const record = payload;
284
+ const content = record.content;
285
+ if (typeof content === "string") return content;
286
+ if (!Array.isArray(content)) return void 0;
287
+ const parts = [];
288
+ for (const item of content) {
289
+ if (!item || typeof item !== "object") continue;
290
+ const text = item.text;
291
+ if (typeof text === "string") parts.push(text);
292
+ }
293
+ return parts.length > 0 ? parts.join("\n") : void 0;
294
+ }
295
+
296
+ // src/codex.ts
297
+ var DEFAULT_CODEX_HOME = join(homedir(), ".codex");
298
+ var LEGACY_AGENT_MESSAGE_IDLE_GRACE_MS = 4e3;
299
+ async function listRolloutFiles(root) {
300
+ const files = [];
301
+ const entries = await fs.readdir(root, { withFileTypes: true });
302
+ for (const entry of entries) {
303
+ const fullPath = join(root, entry.name);
304
+ if (entry.isDirectory()) {
305
+ files.push(...await listRolloutFiles(fullPath));
306
+ } else if (entry.isFile() && isRolloutFile(fullPath)) {
307
+ files.push(fullPath);
308
+ }
309
+ }
310
+ return files;
311
+ }
312
+ async function readNewLines(filePath, fileState) {
313
+ const stat = await fs.stat(filePath);
314
+ if (stat.size < fileState.position) {
315
+ fileState.position = 0;
316
+ fileState.remainder = "";
317
+ }
318
+ if (stat.size === fileState.position) return [];
319
+ const start = fileState.position;
320
+ const end = Math.max(stat.size - 1, start);
321
+ const chunks = [];
322
+ await new Promise((resolve, reject) => {
323
+ const stream = createReadStream(filePath, { start, end });
324
+ stream.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
325
+ stream.on("error", reject);
326
+ stream.on("end", resolve);
327
+ });
328
+ fileState.position = stat.size;
329
+ const content = fileState.remainder + Buffer.concat(chunks).toString("utf-8");
330
+ const lines = content.split("\n");
331
+ fileState.remainder = content.endsWith("\n") ? "" : lines.pop() ?? "";
332
+ return lines.filter((line) => line.trim().length > 0);
333
+ }
334
+ var CodexSessionWatcher = class {
335
+ fileStates = /* @__PURE__ */ new Map();
336
+ scanTimer = null;
337
+ warnedMissing = false;
338
+ sessionsDir;
339
+ state;
340
+ constructor(state2, options) {
341
+ this.state = state2;
342
+ const base = process.env.CODEX_HOME ?? DEFAULT_CODEX_HOME;
343
+ this.sessionsDir = options?.sessionsDir ?? join(base, "sessions");
344
+ }
345
+ start() {
346
+ this.scan();
347
+ this.scanTimer = setInterval(() => {
348
+ this.scan();
349
+ }, CODEX_SESSIONS_SCAN_INTERVAL_MS);
350
+ }
351
+ stop() {
352
+ if (this.scanTimer) {
353
+ clearInterval(this.scanTimer);
354
+ this.scanTimer = null;
355
+ }
356
+ }
357
+ async scan() {
358
+ if (!existsSync(this.sessionsDir)) {
359
+ if (!this.warnedMissing) {
360
+ console.log(`Waiting for Codex sessions at ${this.sessionsDir}`);
361
+ this.warnedMissing = true;
362
+ }
363
+ return;
364
+ }
365
+ this.warnedMissing = false;
366
+ let files = [];
367
+ try {
368
+ files = await listRolloutFiles(this.sessionsDir);
369
+ } catch {
370
+ return;
371
+ }
372
+ for (const filePath of files) {
373
+ const fileState = this.fileStates.get(filePath) ?? {
374
+ position: 0,
375
+ remainder: "",
376
+ sessionId: sessionIdFromPath(filePath),
377
+ hasAssistantPhaseSignals: false
378
+ };
379
+ if (!this.fileStates.has(filePath)) {
380
+ this.fileStates.set(filePath, fileState);
381
+ try {
382
+ const stat = await fs.stat(filePath);
383
+ fileState.position = stat.size;
384
+ } catch {
385
+ continue;
386
+ }
387
+ continue;
388
+ }
389
+ let newLines = [];
390
+ try {
391
+ newLines = await readNewLines(filePath, fileState);
392
+ } catch {
393
+ continue;
394
+ }
395
+ if (newLines.length === 0) {
396
+ this.maybeFlushLegacyIdle(fileState);
397
+ continue;
398
+ }
399
+ for (const line of newLines) {
400
+ const parsed = parseCodexLine(line, fileState.sessionId);
401
+ fileState.sessionId = parsed.sessionId;
402
+ if (parsed.previousSessionId) {
403
+ this.state.removeSession(parsed.previousSessionId);
404
+ fileState.hasAssistantPhaseSignals = false;
405
+ fileState.pendingLegacyIdleAt = void 0;
406
+ }
407
+ if (parsed.assistantMessagePhase) {
408
+ fileState.hasAssistantPhaseSignals = true;
409
+ fileState.pendingLegacyIdleAt = void 0;
410
+ }
411
+ this.state.markCodexSessionSeen(parsed.sessionId, parsed.cwd);
412
+ if (parsed.markWorking || parsed.markActivity) {
413
+ fileState.pendingLegacyIdleAt = void 0;
414
+ this.state.handleCodexActivity({
415
+ sessionId: parsed.sessionId,
416
+ cwd: parsed.cwd
417
+ });
418
+ }
419
+ if (parsed.markWaitingForInput) {
420
+ fileState.pendingLegacyIdleAt = void 0;
421
+ this.state.setWaitingForInput(parsed.sessionId, parsed.cwd);
422
+ }
423
+ if (parsed.markIdle) {
424
+ fileState.pendingLegacyIdleAt = void 0;
425
+ this.state.setCodexIdle(parsed.sessionId, parsed.cwd);
426
+ }
427
+ if (parsed.markLegacyIdleCandidate && !fileState.hasAssistantPhaseSignals) {
428
+ fileState.pendingLegacyIdleAt ??= Date.now();
429
+ }
430
+ }
431
+ this.maybeFlushLegacyIdle(fileState);
432
+ }
433
+ }
434
+ maybeFlushLegacyIdle(fileState) {
435
+ if (!fileState.pendingLegacyIdleAt) return;
436
+ if (Date.now() - fileState.pendingLegacyIdleAt < LEGACY_AGENT_MESSAGE_IDLE_GRACE_MS) {
437
+ return;
438
+ }
439
+ this.state.setCodexIdle(fileState.sessionId);
440
+ fileState.pendingLegacyIdleAt = void 0;
441
+ }
442
+ };
443
+
444
+ // src/mobile.ts
445
+ import { randomBytes, randomInt } from "crypto";
446
+ var ExtensionPairingManager = class {
447
+ constructor(now = () => Date.now()) {
448
+ this.now = now;
449
+ }
450
+ pairing = null;
451
+ startPairing(regenerateCode = false) {
452
+ this.expireIfNeeded();
453
+ if (this.pairing && !regenerateCode) {
454
+ return { ...this.pairing };
455
+ }
456
+ const next = {
457
+ code: randomInt(0, 1e6).toString().padStart(6, "0"),
458
+ expiresAt: this.now() + MOBILE_PAIRING_TTL_MS
459
+ };
460
+ this.pairing = next;
461
+ return { ...next };
462
+ }
463
+ getStatus() {
464
+ this.expireIfNeeded();
465
+ return {
466
+ active: this.pairing !== null,
467
+ expiresAt: this.pairing?.expiresAt ?? null
468
+ };
469
+ }
470
+ confirmPairingCode(code) {
471
+ this.expireIfNeeded();
472
+ if (!this.pairing) return false;
473
+ if (code.trim() !== this.pairing.code) return false;
474
+ this.pairing = null;
475
+ return true;
476
+ }
477
+ expireIfNeeded() {
478
+ if (!this.pairing) return;
479
+ if (this.now() >= this.pairing.expiresAt) {
480
+ this.pairing = null;
481
+ }
482
+ }
483
+ };
484
+ var MobileQrPairingManager = class {
485
+ constructor(now = () => Date.now()) {
486
+ this.now = now;
487
+ }
488
+ pairing = null;
489
+ startPairing(refreshQr = false) {
490
+ this.expireIfNeeded();
491
+ if (this.pairing && !refreshQr) {
492
+ return { ...this.pairing };
493
+ }
494
+ if (this.pairing && refreshQr) {
495
+ const nextQrExpiry = this.now() + MOBILE_QR_PAIRING_TTL_MS;
496
+ const refreshed = {
497
+ ...this.pairing,
498
+ qrNonce: randomBytes(16).toString("hex"),
499
+ qrExpiresAt: Math.max(nextQrExpiry, this.pairing.qrExpiresAt + 1)
500
+ };
501
+ this.pairing = refreshed;
502
+ return { ...refreshed };
503
+ }
504
+ const next = {
505
+ expiresAt: this.now() + MOBILE_PAIRING_TTL_MS,
506
+ qrNonce: randomBytes(16).toString("hex"),
507
+ qrExpiresAt: this.now() + MOBILE_QR_PAIRING_TTL_MS
508
+ };
509
+ this.pairing = next;
510
+ return { ...next };
511
+ }
512
+ getStatus() {
513
+ this.expireIfNeeded();
514
+ return {
515
+ active: this.pairing !== null,
516
+ expiresAt: this.pairing?.expiresAt ?? null
517
+ };
518
+ }
519
+ confirmPairingQrNonce(qrNonce) {
520
+ this.expireIfNeeded();
521
+ if (!this.pairing) return false;
522
+ if (this.now() >= this.pairing.qrExpiresAt) return false;
523
+ if (qrNonce.trim() !== this.pairing.qrNonce) return false;
524
+ this.pairing = null;
525
+ return true;
526
+ }
527
+ expireIfNeeded() {
528
+ if (!this.pairing) return;
529
+ if (this.now() >= this.pairing.expiresAt) {
530
+ this.pairing = null;
531
+ }
532
+ }
533
+ };
534
+ function createServerInstanceId() {
535
+ return randomBytes(8).toString("hex");
536
+ }
537
+
538
+ // src/mdns.ts
539
+ import { Bonjour } from "bonjour-service";
540
+ function publishMobileService({
541
+ name,
542
+ type,
543
+ port,
544
+ instanceId
545
+ }) {
546
+ const bonjour = new Bonjour();
547
+ const service = bonjour.publish({
548
+ name,
549
+ type,
550
+ port,
551
+ txt: {
552
+ instanceId,
553
+ version: "1"
554
+ }
555
+ });
556
+ return {
557
+ stop: () => new Promise((resolve) => {
558
+ const maybeService = service;
559
+ if (!maybeService || typeof maybeService.stop !== "function") {
560
+ bonjour.destroy();
561
+ resolve();
562
+ return;
563
+ }
564
+ maybeService.stop(() => {
565
+ bonjour.destroy();
566
+ resolve();
567
+ });
568
+ })
569
+ };
570
+ }
571
+
572
+ // src/auth-token.ts
573
+ import { randomBytes as randomBytes2 } from "crypto";
574
+ import { homedir as homedir2 } from "os";
575
+ import { dirname as dirname2, join as join2 } from "path";
576
+ var DEFAULT_TOKEN_DIR = join2(homedir2(), ".codex-blocker");
577
+ var DEFAULT_TOKEN_PATH = join2(DEFAULT_TOKEN_DIR, "token");
578
+ function createAuthToken() {
579
+ return randomBytes2(32).toString("hex");
580
+ }
581
+
582
+ // src/pairing-qr.ts
583
+ import QRCode from "qrcode";
584
+ var PAIRING_QR_FORMAT = "cbm-v1";
585
+ var PAIRING_QR_PREFIX = "CBM1";
586
+ function encodePairingQrPayload(input) {
587
+ const host = encodeURIComponent(input.host);
588
+ const instanceId = encodeURIComponent(input.instanceId);
589
+ const qrNonce = encodeURIComponent(input.qrNonce);
590
+ return `${PAIRING_QR_PREFIX};h=${host};p=${input.port};i=${instanceId};n=${qrNonce};e=${input.expiresAt}`;
591
+ }
592
+ async function renderPairingQr(payload) {
593
+ return QRCode.toString(payload, { type: "terminal", small: true });
594
+ }
595
+
596
+ // src/server.ts
597
+ var RATE_WINDOW_MS = 6e4;
598
+ var RATE_LIMIT = 60;
599
+ var MAX_WS_CONNECTIONS_PER_IP = 3;
600
+ var MOBILE_SERVICE_TYPE = "codex-blocker";
601
+ var PAIR_CONFIRM_WINDOW_MS = 6e4;
602
+ var PAIR_CONFIRM_MAX_FAILURES = 6;
603
+ var PAIR_CONFIRM_LOCKOUT_MS = 2 * 6e4;
604
+ var WS_TOKEN_PROTOCOL_PREFIX = "codex-blocker-token.";
605
+ var INVALID_JSON_SENTINEL = /* @__PURE__ */ Symbol("invalid-json");
606
+ var rateByIp = /* @__PURE__ */ new Map();
607
+ var wsConnectionsByIp = /* @__PURE__ */ new Map();
608
+ var extensionPairConfirmByIp = /* @__PURE__ */ new Map();
609
+ var mobilePairConfirmByIp = /* @__PURE__ */ new Map();
610
+ var CHROME_EXTENSION_ID_PATTERN = /^[a-p]{32}$/;
611
+ function isTrustedChromeExtensionOrigin(origin) {
612
+ if (!origin) return false;
613
+ try {
614
+ const parsed = new URL(origin);
615
+ return parsed.protocol === "chrome-extension:" && CHROME_EXTENSION_ID_PATTERN.test(parsed.hostname);
616
+ } catch {
617
+ return false;
618
+ }
619
+ }
620
+ function canBootstrapExtensionToken(providedToken, allowExtensionOrigin) {
621
+ return Boolean(providedToken && allowExtensionOrigin);
622
+ }
623
+ function isLoopbackClientIp(clientIp) {
624
+ if (!clientIp) return false;
625
+ const normalized = clientIp.startsWith("::ffff:") ? clientIp.slice(7) : clientIp;
626
+ return normalized === "127.0.0.1" || normalized === "::1";
627
+ }
628
+ function getClientIp(req) {
629
+ return req.socket.remoteAddress ?? "unknown";
630
+ }
631
+ function checkRateLimit(ip) {
632
+ const now = Date.now();
633
+ const state2 = rateByIp.get(ip);
634
+ if (!state2 || state2.resetAt <= now) {
635
+ rateByIp.set(ip, { count: 1, resetAt: now + RATE_WINDOW_MS });
636
+ return true;
637
+ }
638
+ if (state2.count >= RATE_LIMIT) return false;
639
+ state2.count += 1;
640
+ return true;
641
+ }
642
+ function getPairConfirmState(lockoutStore, ip) {
643
+ const now = Date.now();
644
+ const current = lockoutStore.get(ip);
645
+ if (!current || current.resetAt <= now) {
646
+ const next = {
647
+ failures: 0,
648
+ resetAt: now + PAIR_CONFIRM_WINDOW_MS,
649
+ lockoutUntil: 0
650
+ };
651
+ lockoutStore.set(ip, next);
652
+ return next;
653
+ }
654
+ return current;
655
+ }
656
+ function canAttemptPairConfirm(lockoutStore, ip) {
657
+ const state2 = getPairConfirmState(lockoutStore, ip);
658
+ return state2.lockoutUntil <= Date.now();
659
+ }
660
+ function getPairConfirmRetryAfterMs(lockoutStore, ip) {
661
+ const state2 = getPairConfirmState(lockoutStore, ip);
662
+ const remaining = state2.lockoutUntil - Date.now();
663
+ return remaining > 0 ? remaining : 0;
664
+ }
665
+ function recordPairConfirmFailure(lockoutStore, ip) {
666
+ const state2 = getPairConfirmState(lockoutStore, ip);
667
+ state2.failures += 1;
668
+ if (state2.failures >= PAIR_CONFIRM_MAX_FAILURES) {
669
+ state2.failures = 0;
670
+ state2.lockoutUntil = Date.now() + PAIR_CONFIRM_LOCKOUT_MS;
671
+ }
672
+ }
673
+ function clearPairConfirmFailures(lockoutStore, ip) {
674
+ lockoutStore.delete(ip);
675
+ }
676
+ function readAuthToken(req, url) {
677
+ const header = req.headers.authorization;
678
+ if (header && header.startsWith("Bearer ")) {
679
+ return header.slice("Bearer ".length).trim();
680
+ }
681
+ const query = url.searchParams.get("token");
682
+ if (query) return query;
683
+ const alt = req.headers["x-codex-blocker-token"];
684
+ if (typeof alt === "string" && alt.length > 0) return alt;
685
+ return null;
686
+ }
687
+ function parseWebSocketProtocols(protocolsHeader) {
688
+ const raw = Array.isArray(protocolsHeader) ? protocolsHeader.join(",") : protocolsHeader ?? "";
689
+ return raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0);
690
+ }
691
+ function readTokenFromWebSocketProtocols(protocolsHeader) {
692
+ const protocols = parseWebSocketProtocols(protocolsHeader);
693
+ for (const protocol of protocols) {
694
+ if (protocol.startsWith(WS_TOKEN_PROTOCOL_PREFIX)) {
695
+ const token = protocol.slice(WS_TOKEN_PROTOCOL_PREFIX.length).trim();
696
+ if (token.length > 0) return token;
697
+ }
698
+ }
699
+ return null;
700
+ }
701
+ function readWebSocketAuthToken(req, url) {
702
+ const protocolToken = readTokenFromWebSocketProtocols(
703
+ req.headers["sec-websocket-protocol"]
704
+ );
705
+ if (protocolToken) return protocolToken;
706
+ return readAuthToken(req, url);
707
+ }
708
+ function decrementWsConnectionCount(clientIp) {
709
+ const next = (wsConnectionsByIp.get(clientIp) ?? 1) - 1;
710
+ if (next <= 0) {
711
+ wsConnectionsByIp.delete(clientIp);
712
+ return;
713
+ }
714
+ wsConnectionsByIp.set(clientIp, next);
715
+ }
716
+ function sendJson(res, data, status = 200) {
717
+ res.writeHead(status, { "Content-Type": "application/json" });
718
+ res.end(JSON.stringify(data));
719
+ }
720
+ function normalizeListenHost(bindHost) {
721
+ if (bindHost === "0.0.0.0") {
722
+ return "127.0.0.1";
723
+ }
724
+ return bindHost;
725
+ }
726
+ async function readJsonBody(req, maxBytes = 8192) {
727
+ const chunks = [];
728
+ let totalBytes = 0;
729
+ for await (const chunk of req) {
730
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
731
+ totalBytes += buffer.byteLength;
732
+ if (totalBytes > maxBytes) {
733
+ return INVALID_JSON_SENTINEL;
734
+ }
735
+ chunks.push(buffer);
736
+ }
737
+ if (chunks.length === 0) {
738
+ return {};
739
+ }
740
+ try {
741
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
742
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
743
+ return INVALID_JSON_SENTINEL;
744
+ }
745
+ return parsed;
746
+ } catch {
747
+ return INVALID_JSON_SENTINEL;
748
+ }
749
+ }
750
+ function getResponseHost(req, bindHost, port) {
751
+ if (req.headers.host) {
752
+ return req.headers.host;
753
+ }
754
+ return `${normalizeListenHost(bindHost)}:${port}`;
755
+ }
756
+ function splitHostAndPort(rawHost, fallbackPort) {
757
+ try {
758
+ const parsed = new URL(`http://${rawHost}`);
759
+ const parsedPort = parsed.port ? Number.parseInt(parsed.port, 10) : fallbackPort;
760
+ return {
761
+ host: parsed.hostname,
762
+ port: Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort < 65536 ? parsedPort : fallbackPort
763
+ };
764
+ } catch {
765
+ return { host: rawHost, port: fallbackPort };
766
+ }
767
+ }
768
+ function startServer(port = DEFAULT_PORT, options) {
769
+ const stateInstance = options?.state ?? state;
770
+ const startWatcher = options?.startWatcher ?? true;
771
+ const logBanner = options?.log ?? true;
772
+ const mobileEnabled = options?.mobile ?? true;
773
+ const bindHost = options?.bindHost ?? (mobileEnabled ? "0.0.0.0" : "127.0.0.1");
774
+ const mobileServiceName = options?.mobileServiceName ?? "Codex Blocker";
775
+ const publishMdns = options?.publishMdns ?? mobileEnabled;
776
+ const mobileQrOutput = options?.mobileQrOutput ?? true;
777
+ const autoStartMobilePairing = options?.autoStartMobilePairing ?? true;
778
+ const mobileInstanceId = createServerInstanceId();
779
+ let authToken = null;
780
+ let activePort = port;
781
+ let mdnsService = null;
782
+ const extensionPairing = mobileEnabled ? options?.extensionPairingManager ?? new ExtensionPairingManager() : null;
783
+ const mobileQrPairing = mobileEnabled ? options?.mobileQrPairingManager ?? new MobileQrPairingManager() : null;
784
+ const printExtensionPairingCode = (code) => {
785
+ if (!logBanner) return;
786
+ console.log(
787
+ `
788
+ [Codex Blocker] Extension pairing code (6-digit, extension only): ${code} (expires in 2 minutes)
789
+ `
790
+ );
791
+ };
792
+ const printPairingQr = (host, portToUse, qrNonce, qrExpiresAt) => {
793
+ if (!logBanner || !mobileQrOutput) return;
794
+ const payload = encodePairingQrPayload({
795
+ host,
796
+ port: portToUse,
797
+ instanceId: mobileInstanceId,
798
+ qrNonce,
799
+ expiresAt: qrExpiresAt
800
+ });
801
+ void renderPairingQr(payload).then((terminalQr) => {
802
+ console.log(
803
+ `[Codex Blocker] Mobile app pairing QR (QR-only, expires in 60 seconds):
804
+ ${terminalQr}
805
+ `
806
+ );
807
+ }).catch((error) => {
808
+ console.warn(
809
+ `[Codex Blocker] Failed to render pairing QR: ${error instanceof Error ? error.message : String(error)}`
810
+ );
811
+ });
812
+ };
813
+ const sendPairingToken = (req, res) => {
814
+ if (!authToken) {
815
+ authToken = createAuthToken();
816
+ }
817
+ const host = getResponseHost(req, bindHost, activePort);
818
+ const payload = {
819
+ token: authToken,
820
+ statusUrl: `http://${host}/status`,
821
+ wsUrl: `ws://${host}/ws`
822
+ };
823
+ sendJson(res, payload);
824
+ };
825
+ const server = createServer(async (req, res) => {
826
+ const clientIp = getClientIp(req);
827
+ if (!checkRateLimit(clientIp)) {
828
+ sendJson(res, { error: "Too Many Requests" }, 429);
829
+ return;
830
+ }
831
+ const url = new URL(req.url || "/", `http://localhost:${activePort}`);
832
+ const origin = req.headers.origin;
833
+ const allowExtensionOrigin = isTrustedChromeExtensionOrigin(origin);
834
+ if (allowExtensionOrigin && origin) {
835
+ res.setHeader("Access-Control-Allow-Origin", origin);
836
+ res.setHeader("Vary", "Origin");
837
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
838
+ res.setHeader(
839
+ "Access-Control-Allow-Headers",
840
+ "Content-Type, Authorization, X-Codex-Blocker-Token"
841
+ );
842
+ }
843
+ if (req.method === "OPTIONS") {
844
+ res.writeHead(allowExtensionOrigin ? 204 : 403);
845
+ res.end();
846
+ return;
847
+ }
848
+ if (extensionPairing && mobileQrPairing) {
849
+ if (req.method === "GET" && url.pathname === "/mobile/discovery") {
850
+ const pairingStatus = mobileQrPairing.getStatus();
851
+ const payload = {
852
+ name: mobileServiceName,
853
+ instanceId: mobileInstanceId,
854
+ port: activePort,
855
+ pairingRequired: !authToken,
856
+ pairingExpiresAt: pairingStatus.expiresAt
857
+ };
858
+ sendJson(res, payload);
859
+ return;
860
+ }
861
+ if (req.method === "POST" && url.pathname === "/extension/pair/start") {
862
+ const body = await readJsonBody(req);
863
+ if (body === INVALID_JSON_SENTINEL) {
864
+ sendJson(res, { error: "Invalid JSON" }, 400);
865
+ return;
866
+ }
867
+ const startBody = body;
868
+ const regenerateCode = startBody.regenerateCode === true;
869
+ const pairingWasActive = extensionPairing.getStatus().active;
870
+ const pairingCode = extensionPairing.startPairing(regenerateCode);
871
+ if (!pairingWasActive || regenerateCode) {
872
+ printExtensionPairingCode(pairingCode.code);
873
+ }
874
+ const payload = {
875
+ expiresAt: pairingCode.expiresAt
876
+ };
877
+ sendJson(res, payload);
878
+ return;
879
+ }
880
+ if (req.method === "POST" && url.pathname === "/extension/pair/confirm") {
881
+ if (!canAttemptPairConfirm(extensionPairConfirmByIp, clientIp)) {
882
+ const retryAfterMs = getPairConfirmRetryAfterMs(extensionPairConfirmByIp, clientIp);
883
+ if (retryAfterMs > 0) {
884
+ res.setHeader("Retry-After", String(Math.ceil(retryAfterMs / 1e3)));
885
+ }
886
+ sendJson(
887
+ res,
888
+ {
889
+ error: "Too Many Requests",
890
+ code: "pair_confirm_locked",
891
+ retryAfterMs
892
+ },
893
+ 429
894
+ );
895
+ return;
896
+ }
897
+ const body = await readJsonBody(req);
898
+ if (body === INVALID_JSON_SENTINEL) {
899
+ sendJson(res, { error: "Invalid JSON" }, 400);
900
+ return;
901
+ }
902
+ const confirmBody = body;
903
+ const code = typeof confirmBody.code === "string" ? confirmBody.code.trim() : "";
904
+ if (code.length === 0) {
905
+ sendJson(res, { error: "Provide extension pairing code" }, 400);
906
+ return;
907
+ }
908
+ const confirmed = extensionPairing.confirmPairingCode(code);
909
+ if (!confirmed) {
910
+ recordPairConfirmFailure(extensionPairConfirmByIp, clientIp);
911
+ sendJson(res, { error: "Invalid or expired extension pairing code" }, 401);
912
+ return;
913
+ }
914
+ clearPairConfirmFailures(extensionPairConfirmByIp, clientIp);
915
+ sendPairingToken(req, res);
916
+ return;
917
+ }
918
+ if (req.method === "POST" && url.pathname === "/mobile/pair/start") {
919
+ const body = await readJsonBody(req);
920
+ if (body === INVALID_JSON_SENTINEL) {
921
+ sendJson(res, { error: "Invalid JSON" }, 400);
922
+ return;
923
+ }
924
+ const startBody = body;
925
+ const refreshQr = startBody.refreshQr === true;
926
+ const pairingWasActive = mobileQrPairing.getStatus().active;
927
+ const pairingCode = mobileQrPairing.startPairing(refreshQr);
928
+ if (!pairingWasActive || refreshQr) {
929
+ const rawHost = getResponseHost(req, bindHost, activePort);
930
+ const hostInfo = splitHostAndPort(rawHost, activePort);
931
+ printPairingQr(
932
+ hostInfo.host,
933
+ hostInfo.port,
934
+ pairingCode.qrNonce,
935
+ pairingCode.qrExpiresAt
936
+ );
937
+ }
938
+ const payload = {
939
+ expiresAt: pairingCode.expiresAt,
940
+ qrExpiresAt: pairingCode.qrExpiresAt,
941
+ qrFormat: PAIRING_QR_FORMAT
942
+ };
943
+ sendJson(res, payload);
944
+ return;
945
+ }
946
+ if (req.method === "POST" && url.pathname === "/mobile/pair/confirm") {
947
+ if (!canAttemptPairConfirm(mobilePairConfirmByIp, clientIp)) {
948
+ const retryAfterMs = getPairConfirmRetryAfterMs(mobilePairConfirmByIp, clientIp);
949
+ if (retryAfterMs > 0) {
950
+ res.setHeader("Retry-After", String(Math.ceil(retryAfterMs / 1e3)));
951
+ }
952
+ sendJson(
953
+ res,
954
+ {
955
+ error: "Too Many Requests",
956
+ code: "pair_confirm_locked",
957
+ retryAfterMs
958
+ },
959
+ 429
960
+ );
961
+ return;
962
+ }
963
+ const body = await readJsonBody(req);
964
+ if (body === INVALID_JSON_SENTINEL) {
965
+ sendJson(res, { error: "Invalid JSON" }, 400);
966
+ return;
967
+ }
968
+ const confirmBody = body;
969
+ const qrNonce = typeof confirmBody.qrNonce === "string" ? confirmBody.qrNonce.trim() : "";
970
+ if (qrNonce.length === 0) {
971
+ sendJson(res, { error: "Provide mobile QR nonce" }, 400);
972
+ return;
973
+ }
974
+ const confirmed = mobileQrPairing.confirmPairingQrNonce(qrNonce);
975
+ if (!confirmed) {
976
+ recordPairConfirmFailure(mobilePairConfirmByIp, clientIp);
977
+ sendJson(res, { error: "Invalid or expired QR nonce" }, 401);
978
+ return;
979
+ }
980
+ clearPairConfirmFailures(mobilePairConfirmByIp, clientIp);
981
+ sendPairingToken(req, res);
982
+ return;
983
+ }
984
+ }
985
+ const providedToken = readAuthToken(req, url);
986
+ if (authToken) {
987
+ if (!providedToken || providedToken !== authToken) {
988
+ sendJson(res, { error: "Unauthorized" }, 401);
989
+ return;
990
+ }
991
+ } else {
992
+ sendJson(res, { error: "Unauthorized" }, 401);
993
+ return;
994
+ }
995
+ if (req.method === "GET" && url.pathname === "/status") {
996
+ sendJson(res, stateInstance.getStatus());
997
+ return;
998
+ }
999
+ sendJson(res, { error: "Not found" }, 404);
1000
+ });
1001
+ const wss = new WebSocketServer({ server, path: "/ws" });
1002
+ wss.on("connection", (ws, req) => {
1003
+ const wsUrl = new URL(req.url || "", `http://localhost:${activePort}`);
1004
+ const providedToken = readWebSocketAuthToken(req, wsUrl);
1005
+ const clientIp = getClientIp(req);
1006
+ const allowExtensionOrigin = isTrustedChromeExtensionOrigin(req.headers.origin);
1007
+ const currentConnections = wsConnectionsByIp.get(clientIp) ?? 0;
1008
+ if (currentConnections >= MAX_WS_CONNECTIONS_PER_IP) {
1009
+ ws.close(1013, "Too many connections");
1010
+ return;
1011
+ }
1012
+ if (authToken) {
1013
+ if (!providedToken || providedToken !== authToken) {
1014
+ ws.close(1008, "Unauthorized");
1015
+ return;
1016
+ }
1017
+ } else if (canBootstrapExtensionToken(providedToken, allowExtensionOrigin)) {
1018
+ authToken = providedToken;
1019
+ } else {
1020
+ ws.close(1008, "Unauthorized");
1021
+ return;
1022
+ }
1023
+ wsConnectionsByIp.set(clientIp, currentConnections + 1);
1024
+ const unsubscribe = stateInstance.subscribe((message) => {
1025
+ if (ws.readyState === WebSocket.OPEN) {
1026
+ ws.send(JSON.stringify(message));
1027
+ }
1028
+ });
1029
+ ws.on("message", (data) => {
1030
+ try {
1031
+ const message = JSON.parse(data.toString());
1032
+ if (message.type === "ping") {
1033
+ ws.send(JSON.stringify({ type: "pong" }));
1034
+ }
1035
+ } catch {
1036
+ }
1037
+ });
1038
+ ws.on("close", () => {
1039
+ unsubscribe();
1040
+ decrementWsConnectionCount(clientIp);
1041
+ });
1042
+ ws.on("error", () => {
1043
+ unsubscribe();
1044
+ decrementWsConnectionCount(clientIp);
1045
+ });
1046
+ });
1047
+ const codexWatcher = new CodexSessionWatcher(stateInstance, {
1048
+ sessionsDir: options?.sessionsDir
1049
+ });
1050
+ if (startWatcher) {
1051
+ codexWatcher.start();
1052
+ }
1053
+ let resolveReady = () => {
1054
+ };
1055
+ const ready = new Promise((resolve) => {
1056
+ resolveReady = resolve;
1057
+ });
1058
+ const handle = {
1059
+ port,
1060
+ ready,
1061
+ close: async () => {
1062
+ stateInstance.destroy();
1063
+ codexWatcher.stop();
1064
+ if (mdnsService) {
1065
+ await mdnsService.stop();
1066
+ mdnsService = null;
1067
+ }
1068
+ await new Promise((resolve) => wss.close(() => resolve()));
1069
+ await new Promise((resolve) => server.close(() => resolve()));
1070
+ }
1071
+ };
1072
+ server.listen(port, bindHost, () => {
1073
+ const address = server.address();
1074
+ const actualPort = typeof address === "object" && address ? address.port : port;
1075
+ handle.port = actualPort;
1076
+ activePort = actualPort;
1077
+ resolveReady(actualPort);
1078
+ if (extensionPairing && mobileQrPairing && autoStartMobilePairing) {
1079
+ if (logBanner) {
1080
+ const pairingSummary = mobileQrOutput ? "extension uses 6-digit code only; mobile app uses QR only." : "extension uses 6-digit code only.";
1081
+ console.log(`[Codex Blocker] Pairing paths: ${pairingSummary}`);
1082
+ }
1083
+ const extensionPairingCode = extensionPairing.startPairing();
1084
+ printExtensionPairingCode(extensionPairingCode.code);
1085
+ const mobilePairingCode = mobileQrPairing.startPairing();
1086
+ printPairingQr(
1087
+ "codex-blocker.local",
1088
+ actualPort,
1089
+ mobilePairingCode.qrNonce,
1090
+ mobilePairingCode.qrExpiresAt
1091
+ );
1092
+ if (publishMdns) {
1093
+ try {
1094
+ mdnsService = publishMobileService({
1095
+ name: mobileServiceName,
1096
+ type: MOBILE_SERVICE_TYPE,
1097
+ port: actualPort,
1098
+ instanceId: mobileInstanceId
1099
+ });
1100
+ } catch (error) {
1101
+ if (logBanner) {
1102
+ console.warn(
1103
+ `[Codex Blocker] Failed to publish mDNS service: ${error instanceof Error ? error.message : String(error)}`
1104
+ );
1105
+ }
1106
+ }
1107
+ }
1108
+ }
1109
+ if (!logBanner) return;
1110
+ const displayHost = bindHost === "0.0.0.0" ? "localhost" : bindHost;
1111
+ const mobileLine = !mobileEnabled ? "" : mobileQrOutput ? `
1112
+ \u2502 Mobile: enabled (${mobileServiceName}) \u2502` : "\n\u2502 Mobile: extension-only \u2502";
1113
+ console.log(`
1114
+ \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
1115
+ \u2502 \u2502
1116
+ \u2502 Codex Blocker Server \u2502
1117
+ \u2502 \u2502
1118
+ \u2502 HTTP: http://${displayHost}:${actualPort} \u2502
1119
+ \u2502 WebSocket: ws://${displayHost}:${actualPort}/ws \u2502${mobileLine}
1120
+ \u2502 \u2502
1121
+ \u2502 Watching Codex sessions... \u2502
1122
+ \u2502 \u2502
1123
+ \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
1124
+ `);
1125
+ });
1126
+ process.once("SIGINT", () => {
1127
+ if (logBanner) {
1128
+ console.log("\nShutting down...");
1129
+ }
1130
+ void handle.close().then(() => process.exit(0));
1131
+ });
1132
+ return handle;
1133
+ }
1134
+
1135
+ export {
1136
+ DEFAULT_PORT,
1137
+ isTrustedChromeExtensionOrigin,
1138
+ isLoopbackClientIp,
1139
+ startServer
1140
+ };