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.
- package/README.md +12 -9
- package/dist/bin.js +9 -2
- package/dist/chunk-ZDUKZXM4.js +1140 -0
- package/dist/server.d.ts +44 -2
- package/dist/server.js +5 -1
- package/package.json +4 -1
- package/dist/chunk-SOAB3RMQ.js +0 -574
|
@@ -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
|
+
};
|