codex-blocker 0.1.2 → 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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # codex-blocker
2
2
 
3
- CLI tool and server for Codex Blocker block distracting websites unless Codex is actively running.
3
+ CLI tool and server for Codex Blocker -- block distracting websites unless Codex is actively running.
4
4
 
5
5
  ## Installation
6
6
 
@@ -41,18 +41,18 @@ npx codex-blocker --version
41
41
 
42
42
  ## How It Works
43
43
 
44
- 1. **Codex sessions** The server tails Codex session logs under `~/.codex/sessions`
45
- to detect activity. It marks a session working on your prompt and on intermediate
44
+ 1. **Codex sessions** -- The server tails Codex session logs under `~/.codex/sessions`
45
+ to detect activity. It marks a session "working" on your prompt and on intermediate
46
46
  assistant/tool activity, marks `waiting_for_input` when Codex emits
47
- `request_user_input`, and marks idle when it sees a terminal assistant reply
47
+ `request_user_input`, and marks "idle" when it sees a terminal assistant reply
48
48
  (`phase: "final_answer"`), with legacy fallback support for older Codex logs.
49
49
 
50
- 2. **Server** Runs on localhost and:
50
+ 2. **Server** -- Runs on localhost and:
51
51
  - Tracks active Codex sessions
52
52
  - Marks sessions "working" when new log lines arrive
53
53
  - Broadcasts state via WebSocket to the Chrome extension
54
54
 
55
- 3. **Extension** Connects to the server and:
55
+ 3. **Extension** -- Connects to the server and:
56
56
  - Blocks configured sites when no sessions are working, or when any session is waiting for user input
57
57
  - Shows a modal overlay (soft block, not network block)
58
58
  - Updates in real-time without page refresh
@@ -82,7 +82,7 @@ Connect to `ws://localhost:8765/ws` to receive real-time state updates:
82
82
  ## Programmatic Usage
83
83
 
84
84
  ```typescript
85
- import { startServer } from 'codex-blocker';
85
+ import { startServer } from "codex-blocker";
86
86
 
87
87
  // Start on default port (8765)
88
88
  startServer();
package/dist/bin.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  DEFAULT_PORT,
4
4
  startServer
5
- } from "./chunk-KNFNSOAX.js";
5
+ } from "./chunk-ZDUKZXM4.js";
6
6
 
7
7
  // src/bin.ts
8
8
  import { createRequire } from "module";
@@ -43,6 +43,10 @@ function removeCodexSetup() {
43
43
  var require2 = createRequire(import.meta.url);
44
44
  var { version } = require2("../package.json");
45
45
  var args = process.argv.slice(2);
46
+ function isWindowsOrWslRuntime() {
47
+ if (process.platform === "win32") return true;
48
+ return Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
49
+ }
46
50
  function prompt(question) {
47
51
  const rl = createInterface({
48
52
  input: process.stdin,
@@ -109,6 +113,9 @@ async function main() {
109
113
  console.log("");
110
114
  }
111
115
  }
112
- startServer(port);
116
+ startServer(port, {
117
+ mobile: false,
118
+ bindHost: isWindowsOrWslRuntime() ? "0.0.0.0" : "127.0.0.1"
119
+ });
113
120
  }
114
121
  main();
@@ -1,14 +1,13 @@
1
1
  // src/server.ts
2
2
  import { createServer } from "http";
3
- import { existsSync as existsSync2, mkdirSync, readFileSync, writeFileSync } from "fs";
4
- import { homedir as homedir2 } from "os";
5
- import { dirname as dirname2, join as join2 } from "path";
6
3
  import { WebSocketServer, WebSocket } from "ws";
7
4
 
8
5
  // src/types.ts
9
6
  var DEFAULT_PORT = 8765;
10
7
  var SESSION_TIMEOUT_MS = 5 * 60 * 1e3;
11
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;
12
11
 
13
12
  // src/state.ts
14
13
  var SessionState = class {
@@ -442,31 +441,189 @@ var CodexSessionWatcher = class {
442
441
  }
443
442
  };
444
443
 
445
- // src/server.ts
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";
446
576
  var DEFAULT_TOKEN_DIR = join2(homedir2(), ".codex-blocker");
447
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
448
597
  var RATE_WINDOW_MS = 6e4;
449
598
  var RATE_LIMIT = 60;
450
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");
451
606
  var rateByIp = /* @__PURE__ */ new Map();
452
607
  var wsConnectionsByIp = /* @__PURE__ */ new Map();
453
- function loadToken(tokenPath) {
454
- if (!existsSync2(tokenPath)) return null;
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;
455
613
  try {
456
- return readFileSync(tokenPath, "utf-8").trim() || null;
614
+ const parsed = new URL(origin);
615
+ return parsed.protocol === "chrome-extension:" && CHROME_EXTENSION_ID_PATTERN.test(parsed.hostname);
457
616
  } catch {
458
- return null;
617
+ return false;
459
618
  }
460
619
  }
461
- function saveToken(tokenPath, token) {
462
- const tokenDir = dirname2(tokenPath);
463
- if (!existsSync2(tokenDir)) {
464
- mkdirSync(tokenDir, { recursive: true });
465
- }
466
- writeFileSync(tokenPath, token, "utf-8");
620
+ function canBootstrapExtensionToken(providedToken, allowExtensionOrigin) {
621
+ return Boolean(providedToken && allowExtensionOrigin);
467
622
  }
468
- function isChromeExtensionOrigin(origin) {
469
- return Boolean(origin && origin.startsWith("chrome-extension://"));
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";
470
627
  }
471
628
  function getClientIp(req) {
472
629
  return req.socket.remoteAddress ?? "unknown";
@@ -482,6 +639,40 @@ function checkRateLimit(ip) {
482
639
  state2.count += 1;
483
640
  return true;
484
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
+ }
485
676
  function readAuthToken(req, url) {
486
677
  const header = req.headers.authorization;
487
678
  if (header && header.startsWith("Bearer ")) {
@@ -493,26 +684,154 @@ function readAuthToken(req, url) {
493
684
  if (typeof alt === "string" && alt.length > 0) return alt;
494
685
  return null;
495
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
+ }
496
716
  function sendJson(res, data, status = 200) {
497
717
  res.writeHead(status, { "Content-Type": "application/json" });
498
718
  res.end(JSON.stringify(data));
499
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
+ }
500
768
  function startServer(port = DEFAULT_PORT, options) {
501
769
  const stateInstance = options?.state ?? state;
502
- const tokenPath = options?.tokenPath ?? DEFAULT_TOKEN_PATH;
503
770
  const startWatcher = options?.startWatcher ?? true;
504
771
  const logBanner = options?.log ?? true;
505
- let authToken = loadToken(tokenPath);
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
+ };
506
825
  const server = createServer(async (req, res) => {
507
826
  const clientIp = getClientIp(req);
508
827
  if (!checkRateLimit(clientIp)) {
509
828
  sendJson(res, { error: "Too Many Requests" }, 429);
510
829
  return;
511
830
  }
512
- const url = new URL(req.url || "/", `http://localhost:${port}`);
831
+ const url = new URL(req.url || "/", `http://localhost:${activePort}`);
513
832
  const origin = req.headers.origin;
514
- const allowOrigin = isChromeExtensionOrigin(origin);
515
- if (allowOrigin && origin) {
833
+ const allowExtensionOrigin = isTrustedChromeExtensionOrigin(origin);
834
+ if (allowExtensionOrigin && origin) {
516
835
  res.setHeader("Access-Control-Allow-Origin", origin);
517
836
  res.setHeader("Vary", "Origin");
518
837
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
@@ -522,19 +841,153 @@ function startServer(port = DEFAULT_PORT, options) {
522
841
  );
523
842
  }
524
843
  if (req.method === "OPTIONS") {
525
- res.writeHead(allowOrigin ? 204 : 403);
844
+ res.writeHead(allowExtensionOrigin ? 204 : 403);
526
845
  res.end();
527
846
  return;
528
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
+ }
529
985
  const providedToken = readAuthToken(req, url);
530
986
  if (authToken) {
531
987
  if (!providedToken || providedToken !== authToken) {
532
988
  sendJson(res, { error: "Unauthorized" }, 401);
533
989
  return;
534
990
  }
535
- } else if (providedToken && allowOrigin) {
536
- authToken = providedToken;
537
- saveToken(tokenPath, providedToken);
538
991
  } else {
539
992
  sendJson(res, { error: "Unauthorized" }, 401);
540
993
  return;
@@ -547,11 +1000,10 @@ function startServer(port = DEFAULT_PORT, options) {
547
1000
  });
548
1001
  const wss = new WebSocketServer({ server, path: "/ws" });
549
1002
  wss.on("connection", (ws, req) => {
550
- const wsUrl = new URL(req.url || "", `http://localhost:${port}`);
551
- const providedToken = wsUrl.searchParams.get("token");
552
- const origin = req.headers.origin;
553
- const allowOrigin = isChromeExtensionOrigin(origin);
1003
+ const wsUrl = new URL(req.url || "", `http://localhost:${activePort}`);
1004
+ const providedToken = readWebSocketAuthToken(req, wsUrl);
554
1005
  const clientIp = getClientIp(req);
1006
+ const allowExtensionOrigin = isTrustedChromeExtensionOrigin(req.headers.origin);
555
1007
  const currentConnections = wsConnectionsByIp.get(clientIp) ?? 0;
556
1008
  if (currentConnections >= MAX_WS_CONNECTIONS_PER_IP) {
557
1009
  ws.close(1013, "Too many connections");
@@ -562,9 +1014,8 @@ function startServer(port = DEFAULT_PORT, options) {
562
1014
  ws.close(1008, "Unauthorized");
563
1015
  return;
564
1016
  }
565
- } else if (providedToken && allowOrigin) {
1017
+ } else if (canBootstrapExtensionToken(providedToken, allowExtensionOrigin)) {
566
1018
  authToken = providedToken;
567
- saveToken(tokenPath, providedToken);
568
1019
  } else {
569
1020
  ws.close(1008, "Unauthorized");
570
1021
  return;
@@ -586,17 +1037,11 @@ function startServer(port = DEFAULT_PORT, options) {
586
1037
  });
587
1038
  ws.on("close", () => {
588
1039
  unsubscribe();
589
- wsConnectionsByIp.set(
590
- clientIp,
591
- Math.max(0, (wsConnectionsByIp.get(clientIp) ?? 1) - 1)
592
- );
1040
+ decrementWsConnectionCount(clientIp);
593
1041
  });
594
1042
  ws.on("error", () => {
595
1043
  unsubscribe();
596
- wsConnectionsByIp.set(
597
- clientIp,
598
- Math.max(0, (wsConnectionsByIp.get(clientIp) ?? 1) - 1)
599
- );
1044
+ decrementWsConnectionCount(clientIp);
600
1045
  });
601
1046
  });
602
1047
  const codexWatcher = new CodexSessionWatcher(stateInstance, {
@@ -616,23 +1061,62 @@ function startServer(port = DEFAULT_PORT, options) {
616
1061
  close: async () => {
617
1062
  stateInstance.destroy();
618
1063
  codexWatcher.stop();
1064
+ if (mdnsService) {
1065
+ await mdnsService.stop();
1066
+ mdnsService = null;
1067
+ }
619
1068
  await new Promise((resolve) => wss.close(() => resolve()));
620
1069
  await new Promise((resolve) => server.close(() => resolve()));
621
1070
  }
622
1071
  };
623
- server.listen(port, "127.0.0.1", () => {
1072
+ server.listen(port, bindHost, () => {
624
1073
  const address = server.address();
625
1074
  const actualPort = typeof address === "object" && address ? address.port : port;
626
1075
  handle.port = actualPort;
1076
+ activePort = actualPort;
627
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
+ }
628
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";
629
1113
  console.log(`
630
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
631
1115
  \u2502 \u2502
632
1116
  \u2502 Codex Blocker Server \u2502
633
1117
  \u2502 \u2502
634
- \u2502 HTTP: http://localhost:${actualPort} \u2502
635
- \u2502 WebSocket: ws://localhost:${actualPort}/ws \u2502
1118
+ \u2502 HTTP: http://${displayHost}:${actualPort} \u2502
1119
+ \u2502 WebSocket: ws://${displayHost}:${actualPort}/ws \u2502${mobileLine}
636
1120
  \u2502 \u2502
637
1121
  \u2502 Watching Codex sessions... \u2502
638
1122
  \u2502 \u2502
@@ -650,5 +1134,7 @@ function startServer(port = DEFAULT_PORT, options) {
650
1134
 
651
1135
  export {
652
1136
  DEFAULT_PORT,
1137
+ isTrustedChromeExtensionOrigin,
1138
+ isLoopbackClientIp,
653
1139
  startServer
654
1140
  };
package/dist/server.d.ts CHANGED
@@ -38,12 +38,53 @@ declare class SessionState {
38
38
  destroy(): void;
39
39
  }
40
40
 
41
+ type PairingStatus = {
42
+ active: boolean;
43
+ expiresAt: number | null;
44
+ };
45
+ type ExtensionPairingCode = {
46
+ code: string;
47
+ expiresAt: number;
48
+ };
49
+ declare class ExtensionPairingManager {
50
+ private readonly now;
51
+ private pairing;
52
+ constructor(now?: () => number);
53
+ startPairing(regenerateCode?: boolean): ExtensionPairingCode;
54
+ getStatus(): PairingStatus;
55
+ confirmPairingCode(code: string): boolean;
56
+ private expireIfNeeded;
57
+ }
58
+ type MobileQrPairingCode = {
59
+ expiresAt: number;
60
+ qrNonce: string;
61
+ qrExpiresAt: number;
62
+ };
63
+ declare class MobileQrPairingManager {
64
+ private readonly now;
65
+ private pairing;
66
+ constructor(now?: () => number);
67
+ startPairing(refreshQr?: boolean): MobileQrPairingCode;
68
+ getStatus(): PairingStatus;
69
+ confirmPairingQrNonce(qrNonce: string): boolean;
70
+ private expireIfNeeded;
71
+ }
72
+
73
+ declare function isTrustedChromeExtensionOrigin(origin?: string | null): boolean;
74
+ declare function isLoopbackClientIp(clientIp?: string | null): boolean;
41
75
  type ServerOptions = {
42
76
  sessionsDir?: string;
43
77
  startWatcher?: boolean;
44
- tokenPath?: string;
45
78
  state?: SessionState;
46
79
  log?: boolean;
80
+ bindHost?: string;
81
+ mobile?: boolean;
82
+ mobileServiceName?: string;
83
+ publishMdns?: boolean;
84
+ extensionPairingManager?: ExtensionPairingManager;
85
+ mobileQrPairingManager?: MobileQrPairingManager;
86
+ mobileQrOutput?: boolean;
87
+ autoStartMobilePairing?: boolean;
47
88
  };
48
89
  type ServerHandle = {
49
90
  port: number;
@@ -52,4 +93,4 @@ type ServerHandle = {
52
93
  };
53
94
  declare function startServer(port?: number, options?: ServerOptions): ServerHandle;
54
95
 
55
- export { type ServerHandle, type ServerOptions, startServer };
96
+ export { type ServerHandle, type ServerOptions, isLoopbackClientIp, isTrustedChromeExtensionOrigin, startServer };
package/dist/server.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import {
2
+ isLoopbackClientIp,
3
+ isTrustedChromeExtensionOrigin,
2
4
  startServer
3
- } from "./chunk-KNFNSOAX.js";
5
+ } from "./chunk-ZDUKZXM4.js";
4
6
  export {
7
+ isLoopbackClientIp,
8
+ isTrustedChromeExtensionOrigin,
5
9
  startServer
6
10
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-blocker",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Automatically blocks distracting websites unless Codex is actively running. Forked from Theo Browne's (T3) Claude Blocker",
5
5
  "author": "Adam Blumoff ",
6
6
  "repository": {
@@ -26,10 +26,13 @@
26
26
  "typecheck": "tsc --noEmit"
27
27
  },
28
28
  "dependencies": {
29
+ "bonjour-service": "^1.3.0",
30
+ "qrcode": "^1.5.4",
29
31
  "ws": "^8.18.0"
30
32
  },
31
33
  "devDependencies": {
32
34
  "@types/node": "^22.10.2",
35
+ "@types/qrcode": "^1.5.5",
33
36
  "@types/ws": "^8.5.13",
34
37
  "tsup": "^8.3.5",
35
38
  "tsx": "^4.19.2",