icebear0828 Claude Opus 4.6 commited on
Commit
85aec43
·
1 Parent(s): 19e1649

feat: architecture audit — disguise hardening, update automation, robustness

Browse files

Disguise hardening (P0):
- Enforce HTTP header order from fingerprint.yaml config
- Add browser-level headers (Accept-Encoding, Accept-Language)
- Add Codex-specific request body fields (tools, previous_response_id)
- Protect /debug/fingerprint endpoint (dev/localhost only)

Update automation (P0-P1):
- Integrate appcast version checker into server process (30-min polling)
- Expose update state via /health endpoint
- Externalize model catalog to config/models.yaml
- Fail fast on critical extraction failures (originator, api_base_url)
- Update apply-update.ts to compare models against YAML config

Robustness (P0-P1):
- Add 5xx retry with exponential backoff in chat route (max 2 retries)
- Wrap HTML file reads in try-catch to prevent server crashes
- Add config load try-catch with friendly error messages
- Log persistence errors instead of silently swallowing
- Add retry (1 attempt, 5s delay) for token refresh failures

Code hygiene:
- Delete leftover WHAM dist files
- Mark 6 backward-compat shim methods as @deprecated

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

config/fingerprint.yaml CHANGED
@@ -1,4 +1,16 @@
1
  user_agent_template: "Codex Desktop/{version} ({platform}; {arch})"
2
  auth_domains: ["chatgpt.com", "*.chatgpt.com", "openai.com", "*.openai.com"]
3
  auth_domain_exclusions: ["ab.chatgpt.com"]
4
- header_order: ["Authorization", "ChatGPT-Account-Id", "originator", "User-Agent", "Content-Type"]
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  user_agent_template: "Codex Desktop/{version} ({platform}; {arch})"
2
  auth_domains: ["chatgpt.com", "*.chatgpt.com", "openai.com", "*.openai.com"]
3
  auth_domain_exclusions: ["ab.chatgpt.com"]
4
+ header_order:
5
+ - "Authorization"
6
+ - "ChatGPT-Account-Id"
7
+ - "originator"
8
+ - "User-Agent"
9
+ - "Accept-Encoding"
10
+ - "Accept-Language"
11
+ - "Content-Type"
12
+ - "Accept"
13
+ - "Cookie"
14
+ default_headers:
15
+ Accept-Encoding: "gzip, deflate, br, zstd"
16
+ Accept-Language: "en-US,en;q=0.9"
config/models.yaml ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Codex model catalog — sourced from Codex Desktop `model/list`.
2
+ # Updated by scripts/apply-update.ts when new versions are detected.
3
+
4
+ models:
5
+ - id: "gpt-5.3-codex"
6
+ displayName: "gpt-5.3-codex"
7
+ description: "Latest frontier agentic coding model."
8
+ isDefault: true
9
+ supportedReasoningEfforts:
10
+ - { reasoningEffort: "low", description: "Fast responses with lighter reasoning" }
11
+ - { reasoningEffort: "medium", description: "Balances speed and reasoning depth for everyday tasks" }
12
+ - { reasoningEffort: "high", description: "Greater reasoning depth for complex problems" }
13
+ - { reasoningEffort: "xhigh", description: "Extra high reasoning depth for complex problems" }
14
+ defaultReasoningEffort: "medium"
15
+ inputModalities: ["text", "image"]
16
+ supportsPersonality: true
17
+ upgrade: null
18
+
19
+ - id: "gpt-5.2-codex"
20
+ displayName: "gpt-5.2-codex"
21
+ description: "Frontier agentic coding model."
22
+ isDefault: false
23
+ supportedReasoningEfforts:
24
+ - { reasoningEffort: "low", description: "Fast responses with lighter reasoning" }
25
+ - { reasoningEffort: "medium", description: "Balances speed and reasoning depth for everyday tasks" }
26
+ - { reasoningEffort: "high", description: "Greater reasoning depth for complex problems" }
27
+ - { reasoningEffort: "xhigh", description: "Extra high reasoning depth for complex problems" }
28
+ defaultReasoningEffort: "medium"
29
+ inputModalities: ["text", "image"]
30
+ supportsPersonality: true
31
+ upgrade: "gpt-5.3-codex"
32
+
33
+ - id: "gpt-5.1-codex-max"
34
+ displayName: "gpt-5.1-codex-max"
35
+ description: "Codex-optimized flagship for deep and fast reasoning."
36
+ isDefault: false
37
+ supportedReasoningEfforts:
38
+ - { reasoningEffort: "low", description: "Fast responses with lighter reasoning" }
39
+ - { reasoningEffort: "medium", description: "Balances speed and reasoning depth for everyday tasks" }
40
+ - { reasoningEffort: "high", description: "Greater reasoning depth for complex problems" }
41
+ - { reasoningEffort: "xhigh", description: "Extra high reasoning depth for complex problems" }
42
+ defaultReasoningEffort: "medium"
43
+ inputModalities: ["text", "image"]
44
+ supportsPersonality: false
45
+ upgrade: "gpt-5.3-codex"
46
+
47
+ - id: "gpt-5.2"
48
+ displayName: "gpt-5.2"
49
+ description: "Latest frontier model with improvements across knowledge, reasoning and coding."
50
+ isDefault: false
51
+ supportedReasoningEfforts:
52
+ - { reasoningEffort: "low", description: "Balances speed with some reasoning" }
53
+ - { reasoningEffort: "medium", description: "Solid balance of reasoning depth and latency" }
54
+ - { reasoningEffort: "high", description: "Maximizes reasoning depth for complex problems" }
55
+ - { reasoningEffort: "xhigh", description: "Extra high reasoning for complex problems" }
56
+ defaultReasoningEffort: "medium"
57
+ inputModalities: ["text", "image"]
58
+ supportsPersonality: false
59
+ upgrade: "gpt-5.3-codex"
60
+
61
+ - id: "gpt-5.1-codex-mini"
62
+ displayName: "gpt-5.1-codex-mini"
63
+ description: "Optimized for codex. Cheaper, faster, but less capable."
64
+ isDefault: false
65
+ supportedReasoningEfforts:
66
+ - { reasoningEffort: "medium", description: "Dynamically adjusts reasoning based on the task" }
67
+ - { reasoningEffort: "high", description: "Maximizes reasoning depth for complex problems" }
68
+ defaultReasoningEffort: "medium"
69
+ inputModalities: ["text", "image"]
70
+ supportsPersonality: false
71
+ upgrade: "gpt-5.3-codex"
72
+
73
+ aliases:
74
+ codex: "gpt-5.3-codex"
75
+ codex-max: "gpt-5.1-codex-max"
76
+ codex-mini: "gpt-5.1-codex-mini"
scripts/apply-update.ts CHANGED
@@ -17,7 +17,7 @@ const ROOT = resolve(import.meta.dirname, "..");
17
  const CONFIG_PATH = resolve(ROOT, "config/default.yaml");
18
  const FINGERPRINT_PATH = resolve(ROOT, "config/fingerprint.yaml");
19
  const EXTRACTED_PATH = resolve(ROOT, "data/extracted-fingerprint.json");
20
- const MODELS_PATH = resolve(ROOT, "src/routes/models.ts");
21
  const PROMPTS_DIR = resolve(ROOT, "config/prompts");
22
 
23
  type ChangeType = "auto" | "semi-auto" | "alert";
@@ -119,9 +119,11 @@ function detectChanges(extracted: ExtractedFingerprint): Change[] {
119
  });
120
  }
121
 
122
- // Models — check for additions/removals
123
- const modelsTs = readFileSync(MODELS_PATH, "utf-8");
124
- const currentModels = [...modelsTs.matchAll(/id:\s*"(gpt-[^"]+)"/g)].map((m) => m[1]);
 
 
125
  const extractedModels = extracted.models.filter((m) => m.includes("codex") || currentModels.includes(m));
126
 
127
  const newModels = extractedModels.filter((m) => !currentModels.includes(m));
 
17
  const CONFIG_PATH = resolve(ROOT, "config/default.yaml");
18
  const FINGERPRINT_PATH = resolve(ROOT, "config/fingerprint.yaml");
19
  const EXTRACTED_PATH = resolve(ROOT, "data/extracted-fingerprint.json");
20
+ const MODELS_PATH = resolve(ROOT, "config/models.yaml");
21
  const PROMPTS_DIR = resolve(ROOT, "config/prompts");
22
 
23
  type ChangeType = "auto" | "semi-auto" | "alert";
 
119
  });
120
  }
121
 
122
+ // Models — compare against config/models.yaml
123
+ const modelsYaml = yaml.load(readFileSync(MODELS_PATH, "utf-8")) as {
124
+ models: { id: string }[];
125
+ };
126
+ const currentModels = modelsYaml.models.map((m) => m.id);
127
  const extractedModels = extracted.models.filter((m) => m.includes("codex") || currentModels.includes(m));
128
 
129
  const newModels = extractedModels.filter((m) => !currentModels.includes(m));
scripts/extract-fingerprint.ts CHANGED
@@ -155,6 +155,13 @@ function extractFromMainJs(
155
  if (m) apiBaseUrl = m[0];
156
  }
157
 
 
 
 
 
 
 
 
158
  // Originator
159
  let originator: string | null = null;
160
  const origPattern = patterns.originator;
@@ -163,6 +170,13 @@ function extractFromMainJs(
163
  if (m) originator = m[origPattern.group ?? 0] ?? m[0];
164
  }
165
 
 
 
 
 
 
 
 
166
  // Models — deduplicate, use capture group if specified
167
  const models: Set<string> = new Set();
168
  const modelPattern = patterns.models;
 
155
  if (m) apiBaseUrl = m[0];
156
  }
157
 
158
+ // Fail fast on critical fields
159
+ if (!apiBaseUrl) {
160
+ console.error("[extract] CRITICAL: Failed to extract API base URL from main.js");
161
+ console.error("[extract] The extraction pattern may need updating for this version.");
162
+ throw new Error("Failed to extract critical field: api_base_url");
163
+ }
164
+
165
  // Originator
166
  let originator: string | null = null;
167
  const origPattern = patterns.originator;
 
170
  if (m) originator = m[origPattern.group ?? 0] ?? m[0];
171
  }
172
 
173
+ // Fail fast on critical fields
174
+ if (!originator) {
175
+ console.error("[extract] CRITICAL: Failed to extract originator from main.js");
176
+ console.error("[extract] The extraction pattern may need updating for this version.");
177
+ throw new Error("Failed to extract critical field: originator");
178
+ }
179
+
180
  // Models — deduplicate, use capture group if specified
181
  const models: Set<string> = new Set();
182
  const modelPattern = patterns.models;
src/auth/account-pool.ts CHANGED
@@ -257,6 +257,7 @@ export class AccountPool {
257
  return false;
258
  }
259
 
 
260
  async getToken(): Promise<string | null> {
261
  const acq = this.acquire();
262
  if (!acq) return null;
@@ -265,11 +266,13 @@ export class AccountPool {
265
  return acq.token;
266
  }
267
 
 
268
  getAccountId(): string | null {
269
  const first = [...this.accounts.values()].find((a) => a.status === "active");
270
  return first?.accountId ?? null;
271
  }
272
 
 
273
  getUserInfo(): { email?: string; accountId?: string; planType?: string } | null {
274
  const first = [...this.accounts.values()].find((a) => a.status === "active");
275
  if (!first) return null;
@@ -280,6 +283,7 @@ export class AccountPool {
280
  };
281
  }
282
 
 
283
  getProxyApiKey(): string | null {
284
  const first = [...this.accounts.values()].find((a) => a.status === "active");
285
  return first?.proxyApiKey ?? null;
@@ -292,11 +296,12 @@ export class AccountPool {
292
  return false;
293
  }
294
 
295
- /** Alias for addAccount used by auth routes */
296
  setToken(token: string): void {
297
  this.addAccount(token);
298
  }
299
 
 
300
  clearToken(): void {
301
  this.accounts.clear();
302
  this.acquireLocks.clear();
@@ -390,8 +395,8 @@ export class AccountPool {
390
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
391
  const data: AccountsFile = { accounts: [...this.accounts.values()] };
392
  writeFileSync(ACCOUNTS_FILE, JSON.stringify(data, null, 2), "utf-8");
393
- } catch {
394
- // best-effort
395
  }
396
  }
397
 
 
257
  return false;
258
  }
259
 
260
+ /** @deprecated Use acquire() instead. */
261
  async getToken(): Promise<string | null> {
262
  const acq = this.acquire();
263
  if (!acq) return null;
 
266
  return acq.token;
267
  }
268
 
269
+ /** @deprecated Use acquire() instead. */
270
  getAccountId(): string | null {
271
  const first = [...this.accounts.values()].find((a) => a.status === "active");
272
  return first?.accountId ?? null;
273
  }
274
 
275
+ /** @deprecated Use getAccounts() instead. */
276
  getUserInfo(): { email?: string; accountId?: string; planType?: string } | null {
277
  const first = [...this.accounts.values()].find((a) => a.status === "active");
278
  if (!first) return null;
 
283
  };
284
  }
285
 
286
+ /** @deprecated Use getAccounts() instead. */
287
  getProxyApiKey(): string | null {
288
  const first = [...this.accounts.values()].find((a) => a.status === "active");
289
  return first?.proxyApiKey ?? null;
 
296
  return false;
297
  }
298
 
299
+ /** @deprecated Use addAccount() instead. */
300
  setToken(token: string): void {
301
  this.addAccount(token);
302
  }
303
 
304
+ /** @deprecated Use removeAccount() instead. */
305
  clearToken(): void {
306
  this.accounts.clear();
307
  this.acquireLocks.clear();
 
395
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
396
  const data: AccountsFile = { accounts: [...this.accounts.values()] };
397
  writeFileSync(ACCOUNTS_FILE, JSON.stringify(data, null, 2), "utf-8");
398
+ } catch (err) {
399
+ console.error("[AccountPool] Failed to persist accounts:", err instanceof Error ? err.message : err);
400
  }
401
  }
402
 
src/auth/refresh-scheduler.ts CHANGED
@@ -86,16 +86,24 @@ export class RefreshScheduler {
86
  console.log(`[RefreshScheduler] Refreshing account ${entryId} (${entry.email ?? "?"})`);
87
  this.pool.markStatus(entryId, "refreshing");
88
 
89
- try {
90
- const newToken = await refreshTokenViaCli();
91
- this.pool.updateToken(entryId, newToken);
92
- console.log(`[RefreshScheduler] Account ${entryId} refreshed successfully`);
93
- // Re-schedule for the new token
94
- this.scheduleOne(entryId, newToken);
95
- } catch (err) {
96
- const msg = err instanceof Error ? err.message : String(err);
97
- console.error(`[RefreshScheduler] Failed to refresh ${entryId}: ${msg}`);
98
- this.pool.markStatus(entryId, "expired");
 
 
 
 
 
 
 
 
99
  }
100
  }
101
  }
 
86
  console.log(`[RefreshScheduler] Refreshing account ${entryId} (${entry.email ?? "?"})`);
87
  this.pool.markStatus(entryId, "refreshing");
88
 
89
+ const maxAttempts = 2;
90
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
91
+ try {
92
+ const newToken = await refreshTokenViaCli();
93
+ this.pool.updateToken(entryId, newToken);
94
+ console.log(`[RefreshScheduler] Account ${entryId} refreshed successfully`);
95
+ this.scheduleOne(entryId, newToken);
96
+ return;
97
+ } catch (err) {
98
+ const msg = err instanceof Error ? err.message : String(err);
99
+ if (attempt < maxAttempts) {
100
+ console.warn(`[RefreshScheduler] Refresh attempt ${attempt} failed for ${entryId}: ${msg}, retrying in 5s...`);
101
+ await new Promise((r) => setTimeout(r, 5000));
102
+ } else {
103
+ console.error(`[RefreshScheduler] Failed to refresh ${entryId} after ${maxAttempts} attempts: ${msg}`);
104
+ this.pool.markStatus(entryId, "expired");
105
+ }
106
+ }
107
  }
108
  }
109
  }
src/config.ts CHANGED
@@ -56,6 +56,7 @@ const FingerprintSchema = z.object({
56
  auth_domains: z.array(z.string()),
57
  auth_domain_exclusions: z.array(z.string()),
58
  header_order: z.array(z.string()),
 
59
  });
60
 
61
  export type FingerprintConfig = z.infer<typeof FingerprintSchema>;
 
56
  auth_domains: z.array(z.string()),
57
  auth_domain_exclusions: z.array(z.string()),
58
  header_order: z.array(z.string()),
59
+ default_headers: z.record(z.string()).optional().default({}),
60
  });
61
 
62
  export type FingerprintConfig = z.infer<typeof FingerprintSchema>;
src/fingerprint/manager.ts CHANGED
@@ -7,35 +7,66 @@
7
  import { getConfig, getFingerprint } from "../config.js";
8
  import { extractChatGptAccountId } from "../auth/jwt-utils.js";
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  export function buildHeaders(
11
  token: string,
12
  accountId?: string | null,
13
  ): Record<string, string> {
14
  const config = getConfig();
15
  const fp = getFingerprint();
16
- const headers: Record<string, string> = {};
17
 
18
- headers["Authorization"] = `Bearer ${token}`;
19
 
20
  const acctId = accountId ?? extractChatGptAccountId(token);
21
- if (acctId) headers["ChatGPT-Account-Id"] = acctId;
22
 
23
- headers["originator"] = config.client.originator;
24
 
25
  const ua = fp.user_agent_template
26
  .replace("{version}", config.client.app_version)
27
  .replace("{platform}", config.client.platform)
28
  .replace("{arch}", config.client.arch);
29
- headers["User-Agent"] = ua;
 
 
 
 
 
 
 
30
 
31
- return headers;
32
  }
33
 
34
  export function buildHeadersWithContentType(
35
  token: string,
36
  accountId?: string | null,
37
  ): Record<string, string> {
38
- const headers = buildHeaders(token, accountId);
39
- headers["Content-Type"] = "application/json";
40
- return headers;
 
 
41
  }
 
7
  import { getConfig, getFingerprint } from "../config.js";
8
  import { extractChatGptAccountId } from "../auth/jwt-utils.js";
9
 
10
+ /**
11
+ * Reorder headers according to the fingerprint header_order config.
12
+ * Keys not in the order list are appended at the end.
13
+ */
14
+ function orderHeaders(
15
+ headers: Record<string, string>,
16
+ order: string[],
17
+ ): Record<string, string> {
18
+ const ordered: Record<string, string> = {};
19
+ for (const key of order) {
20
+ if (key in headers) {
21
+ ordered[key] = headers[key];
22
+ }
23
+ }
24
+ for (const key of Object.keys(headers)) {
25
+ if (!(key in ordered)) {
26
+ ordered[key] = headers[key];
27
+ }
28
+ }
29
+ return ordered;
30
+ }
31
+
32
  export function buildHeaders(
33
  token: string,
34
  accountId?: string | null,
35
  ): Record<string, string> {
36
  const config = getConfig();
37
  const fp = getFingerprint();
38
+ const raw: Record<string, string> = {};
39
 
40
+ raw["Authorization"] = `Bearer ${token}`;
41
 
42
  const acctId = accountId ?? extractChatGptAccountId(token);
43
+ if (acctId) raw["ChatGPT-Account-Id"] = acctId;
44
 
45
+ raw["originator"] = config.client.originator;
46
 
47
  const ua = fp.user_agent_template
48
  .replace("{version}", config.client.app_version)
49
  .replace("{platform}", config.client.platform)
50
  .replace("{arch}", config.client.arch);
51
+ raw["User-Agent"] = ua;
52
+
53
+ // Add browser-level default headers (Accept-Encoding, Accept-Language, etc.)
54
+ if (fp.default_headers) {
55
+ for (const [key, value] of Object.entries(fp.default_headers)) {
56
+ raw[key] = value;
57
+ }
58
+ }
59
 
60
+ return orderHeaders(raw, fp.header_order);
61
  }
62
 
63
  export function buildHeadersWithContentType(
64
  token: string,
65
  accountId?: string | null,
66
  ): Record<string, string> {
67
+ const config = getConfig();
68
+ const fp = getFingerprint();
69
+ const raw = buildHeaders(token, accountId);
70
+ raw["Content-Type"] = "application/json";
71
+ return orderHeaders(raw, fp.header_order);
72
  }
src/index.ts CHANGED
@@ -12,12 +12,21 @@ import { createChatRoutes } from "./routes/chat.js";
12
  import modelsApp from "./routes/models.js";
13
  import { createWebRoutes } from "./routes/web.js";
14
  import { CookieJar } from "./proxy/cookie-jar.js";
 
15
 
16
  async function main() {
17
  // Load configuration
18
  console.log("[Init] Loading configuration...");
19
- const config = loadConfig();
20
- loadFingerprint();
 
 
 
 
 
 
 
 
21
 
22
  // Initialize managers
23
  const accountPool = new AccountPool();
@@ -71,6 +80,9 @@ async function main() {
71
  }
72
  console.log();
73
 
 
 
 
74
  serve({
75
  fetch: app.fetch,
76
  hostname: host,
@@ -80,6 +92,7 @@ async function main() {
80
  // Graceful shutdown
81
  const shutdown = () => {
82
  console.log("\n[Shutdown] Cleaning up...");
 
83
  cookieJar.destroy();
84
  refreshScheduler.destroy();
85
  accountPool.destroy();
 
12
  import modelsApp from "./routes/models.js";
13
  import { createWebRoutes } from "./routes/web.js";
14
  import { CookieJar } from "./proxy/cookie-jar.js";
15
+ import { startUpdateChecker, stopUpdateChecker } from "./update-checker.js";
16
 
17
  async function main() {
18
  // Load configuration
19
  console.log("[Init] Loading configuration...");
20
+ let config: ReturnType<typeof loadConfig>;
21
+ try {
22
+ config = loadConfig();
23
+ loadFingerprint();
24
+ } catch (err) {
25
+ const msg = err instanceof Error ? err.message : String(err);
26
+ console.error(`[Init] Failed to load configuration: ${msg}`);
27
+ console.error("[Init] Make sure config/default.yaml and config/fingerprint.yaml exist and are valid YAML.");
28
+ process.exit(1);
29
+ }
30
 
31
  // Initialize managers
32
  const accountPool = new AccountPool();
 
80
  }
81
  console.log();
82
 
83
+ // Start background update checker
84
+ startUpdateChecker();
85
+
86
  serve({
87
  fetch: app.fetch,
88
  hostname: host,
 
92
  // Graceful shutdown
93
  const shutdown = () => {
94
  console.log("\n[Shutdown] Cleaning up...");
95
+ stopUpdateChecker();
96
  cookieJar.destroy();
97
  refreshScheduler.destroy();
98
  accountPool.destroy();
src/proxy/codex-api.ts CHANGED
@@ -25,7 +25,7 @@ export interface CodexResponsesRequest {
25
  /** Optional: tools available to the model */
26
  tools?: unknown[];
27
  /** Optional: previous response ID for multi-turn */
28
- previous_response_id?: string;
29
  }
30
 
31
  export type CodexInputItem =
 
25
  /** Optional: tools available to the model */
26
  tools?: unknown[];
27
  /** Optional: previous response ID for multi-turn */
28
+ previous_response_id?: string | null;
29
  }
30
 
31
  export type CodexInputItem =
src/routes/chat.ts CHANGED
@@ -14,6 +14,30 @@ import {
14
  import { getConfig } from "../config.js";
15
  import type { CookieJar } from "../proxy/cookie-jar.js";
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  export function createChatRoutes(
18
  accountPool: AccountPool,
19
  sessionManager: SessionManager,
@@ -97,7 +121,7 @@ export function createChatRoutes(
97
  let usageInfo: UsageInfo | undefined;
98
 
99
  try {
100
- const rawResponse = await codexApi.createResponse(codexRequest);
101
 
102
  if (req.stream) {
103
  c.header("Content-Type", "text/event-stream");
 
14
  import { getConfig } from "../config.js";
15
  import type { CookieJar } from "../proxy/cookie-jar.js";
16
 
17
+ /** Retry a function on 5xx errors with exponential backoff. */
18
+ async function withRetry<T>(
19
+ fn: () => Promise<T>,
20
+ { maxRetries = 2, baseDelayMs = 1000 }: { maxRetries?: number; baseDelayMs?: number } = {},
21
+ ): Promise<T> {
22
+ let lastError: unknown;
23
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
24
+ try {
25
+ return await fn();
26
+ } catch (err) {
27
+ lastError = err;
28
+ const isRetryable =
29
+ err instanceof CodexApiError && err.status >= 500 && err.status < 600;
30
+ if (!isRetryable || attempt === maxRetries) throw err;
31
+ const delay = baseDelayMs * Math.pow(2, attempt);
32
+ console.warn(
33
+ `[Chat] Retrying after ${err instanceof CodexApiError ? err.status : "error"} (attempt ${attempt + 1}/${maxRetries}, delay ${delay}ms)`,
34
+ );
35
+ await new Promise((r) => setTimeout(r, delay));
36
+ }
37
+ }
38
+ throw lastError;
39
+ }
40
+
41
  export function createChatRoutes(
42
  accountPool: AccountPool,
43
  sessionManager: SessionManager,
 
121
  let usageInfo: UsageInfo | undefined;
122
 
123
  try {
124
+ const rawResponse = await withRetry(() => codexApi.createResponse(codexRequest));
125
 
126
  if (req.stream) {
127
  c.header("Content-Type", "text/event-stream");
src/routes/models.ts CHANGED
@@ -1,4 +1,7 @@
1
  import { Hono } from "hono";
 
 
 
2
  import { getConfig } from "../config.js";
3
  import type { OpenAIModel, OpenAIModelList } from "../types/openai.js";
4
 
@@ -10,7 +13,6 @@ const app = new Hono();
10
  */
11
  export interface CodexModelInfo {
12
  id: string;
13
- model: string;
14
  displayName: string;
15
  description: string;
16
  isDefault: boolean;
@@ -21,99 +23,20 @@ export interface CodexModelInfo {
21
  upgrade: string | null;
22
  }
23
 
24
- // Static model catalog — sourced from `codex app-server` model/list
25
- const MODEL_CATALOG: CodexModelInfo[] = [
26
- {
27
- id: "gpt-5.3-codex",
28
- model: "gpt-5.3-codex",
29
- displayName: "gpt-5.3-codex",
30
- description: "Latest frontier agentic coding model.",
31
- isDefault: true,
32
- supportedReasoningEfforts: [
33
- { reasoningEffort: "low", description: "Fast responses with lighter reasoning" },
34
- { reasoningEffort: "medium", description: "Balances speed and reasoning depth for everyday tasks" },
35
- { reasoningEffort: "high", description: "Greater reasoning depth for complex problems" },
36
- { reasoningEffort: "xhigh", description: "Extra high reasoning depth for complex problems" },
37
- ],
38
- defaultReasoningEffort: "medium",
39
- inputModalities: ["text", "image"],
40
- supportsPersonality: true,
41
- upgrade: null,
42
- },
43
- {
44
- id: "gpt-5.2-codex",
45
- model: "gpt-5.2-codex",
46
- displayName: "gpt-5.2-codex",
47
- description: "Frontier agentic coding model.",
48
- isDefault: false,
49
- supportedReasoningEfforts: [
50
- { reasoningEffort: "low", description: "Fast responses with lighter reasoning" },
51
- { reasoningEffort: "medium", description: "Balances speed and reasoning depth for everyday tasks" },
52
- { reasoningEffort: "high", description: "Greater reasoning depth for complex problems" },
53
- { reasoningEffort: "xhigh", description: "Extra high reasoning depth for complex problems" },
54
- ],
55
- defaultReasoningEffort: "medium",
56
- inputModalities: ["text", "image"],
57
- supportsPersonality: true,
58
- upgrade: "gpt-5.3-codex",
59
- },
60
- {
61
- id: "gpt-5.1-codex-max",
62
- model: "gpt-5.1-codex-max",
63
- displayName: "gpt-5.1-codex-max",
64
- description: "Codex-optimized flagship for deep and fast reasoning.",
65
- isDefault: false,
66
- supportedReasoningEfforts: [
67
- { reasoningEffort: "low", description: "Fast responses with lighter reasoning" },
68
- { reasoningEffort: "medium", description: "Balances speed and reasoning depth for everyday tasks" },
69
- { reasoningEffort: "high", description: "Greater reasoning depth for complex problems" },
70
- { reasoningEffort: "xhigh", description: "Extra high reasoning depth for complex problems" },
71
- ],
72
- defaultReasoningEffort: "medium",
73
- inputModalities: ["text", "image"],
74
- supportsPersonality: false,
75
- upgrade: "gpt-5.3-codex",
76
- },
77
- {
78
- id: "gpt-5.2",
79
- model: "gpt-5.2",
80
- displayName: "gpt-5.2",
81
- description: "Latest frontier model with improvements across knowledge, reasoning and coding.",
82
- isDefault: false,
83
- supportedReasoningEfforts: [
84
- { reasoningEffort: "low", description: "Balances speed with some reasoning" },
85
- { reasoningEffort: "medium", description: "Solid balance of reasoning depth and latency" },
86
- { reasoningEffort: "high", description: "Maximizes reasoning depth for complex problems" },
87
- { reasoningEffort: "xhigh", description: "Extra high reasoning for complex problems" },
88
- ],
89
- defaultReasoningEffort: "medium",
90
- inputModalities: ["text", "image"],
91
- supportsPersonality: false,
92
- upgrade: "gpt-5.3-codex",
93
- },
94
- {
95
- id: "gpt-5.1-codex-mini",
96
- model: "gpt-5.1-codex-mini",
97
- displayName: "gpt-5.1-codex-mini",
98
- description: "Optimized for codex. Cheaper, faster, but less capable.",
99
- isDefault: false,
100
- supportedReasoningEfforts: [
101
- { reasoningEffort: "medium", description: "Dynamically adjusts reasoning based on the task" },
102
- { reasoningEffort: "high", description: "Maximizes reasoning depth for complex problems" },
103
- ],
104
- defaultReasoningEffort: "medium",
105
- inputModalities: ["text", "image"],
106
- supportsPersonality: false,
107
- upgrade: "gpt-5.3-codex",
108
- },
109
- ];
110
-
111
- // Short aliases for convenience
112
- const MODEL_ALIASES: Record<string, string> = {
113
- codex: "gpt-5.3-codex",
114
- "codex-max": "gpt-5.1-codex-max",
115
- "codex-mini": "gpt-5.1-codex-mini",
116
- };
117
 
118
  /**
119
  * Resolve a model name (may be an alias) to a canonical model ID.
 
1
  import { Hono } from "hono";
2
+ import { readFileSync } from "fs";
3
+ import { resolve } from "path";
4
+ import yaml from "js-yaml";
5
  import { getConfig } from "../config.js";
6
  import type { OpenAIModel, OpenAIModelList } from "../types/openai.js";
7
 
 
13
  */
14
  export interface CodexModelInfo {
15
  id: string;
 
16
  displayName: string;
17
  description: string;
18
  isDefault: boolean;
 
23
  upgrade: string | null;
24
  }
25
 
26
+ interface ModelsConfig {
27
+ models: CodexModelInfo[];
28
+ aliases: Record<string, string>;
29
+ }
30
+
31
+ function loadModelConfig(): ModelsConfig {
32
+ const configPath = resolve(process.cwd(), "config/models.yaml");
33
+ const raw = yaml.load(readFileSync(configPath, "utf-8")) as ModelsConfig;
34
+ return raw;
35
+ }
36
+
37
+ const modelConfig = loadModelConfig();
38
+ const MODEL_CATALOG: CodexModelInfo[] = modelConfig.models;
39
+ const MODEL_ALIASES: Record<string, string> = modelConfig.aliases;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  /**
42
  * Resolve a model name (may be an alias) to a canonical model ID.
src/routes/web.ts CHANGED
@@ -3,6 +3,7 @@ import { readFileSync, existsSync } from "fs";
3
  import { resolve } from "path";
4
  import type { AccountPool } from "../auth/account-pool.js";
5
  import { getConfig, getFingerprint } from "../config.js";
 
6
 
7
  export function createWebRoutes(accountPool: AccountPool): Hono {
8
  const app = new Hono();
@@ -10,12 +11,18 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
10
  const publicDir = resolve(process.cwd(), "public");
11
 
12
  app.get("/", (c) => {
13
- if (accountPool.isAuthenticated()) {
14
- const html = readFileSync(resolve(publicDir, "dashboard.html"), "utf-8");
 
 
 
 
15
  return c.html(html);
 
 
 
 
16
  }
17
- const html = readFileSync(resolve(publicDir, "login.html"), "utf-8");
18
- return c.html(html);
19
  });
20
 
21
  app.get("/health", async (c) => {
@@ -27,11 +34,21 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
27
  authenticated,
28
  user: authenticated ? userInfo : null,
29
  pool: poolSummary,
 
30
  timestamp: new Date().toISOString(),
31
  });
32
  });
33
 
34
  app.get("/debug/fingerprint", (c) => {
 
 
 
 
 
 
 
 
 
35
  const config = getConfig();
36
  const fp = getFingerprint();
37
 
 
3
  import { resolve } from "path";
4
  import type { AccountPool } from "../auth/account-pool.js";
5
  import { getConfig, getFingerprint } from "../config.js";
6
+ import { getUpdateState } from "../update-checker.js";
7
 
8
  export function createWebRoutes(accountPool: AccountPool): Hono {
9
  const app = new Hono();
 
11
  const publicDir = resolve(process.cwd(), "public");
12
 
13
  app.get("/", (c) => {
14
+ try {
15
+ if (accountPool.isAuthenticated()) {
16
+ const html = readFileSync(resolve(publicDir, "dashboard.html"), "utf-8");
17
+ return c.html(html);
18
+ }
19
+ const html = readFileSync(resolve(publicDir, "login.html"), "utf-8");
20
  return c.html(html);
21
+ } catch (err) {
22
+ const msg = err instanceof Error ? err.message : String(err);
23
+ console.error(`[Web] Failed to read HTML file: ${msg}`);
24
+ return c.html("<h1>Codex Proxy</h1><p>UI files not found. The API is still available at /v1/chat/completions</p>");
25
  }
 
 
26
  });
27
 
28
  app.get("/health", async (c) => {
 
34
  authenticated,
35
  user: authenticated ? userInfo : null,
36
  pool: poolSummary,
37
+ update: getUpdateState(),
38
  timestamp: new Date().toISOString(),
39
  });
40
  });
41
 
42
  app.get("/debug/fingerprint", (c) => {
43
+ // Only allow in development or from localhost
44
+ const isProduction = process.env.NODE_ENV === "production";
45
+ const remoteAddr = c.req.header("x-forwarded-for") ?? c.req.header("x-real-ip") ?? "";
46
+ const isLocalhost = remoteAddr === "" || remoteAddr === "127.0.0.1" || remoteAddr === "::1";
47
+ if (isProduction && !isLocalhost) {
48
+ c.status(404);
49
+ return c.json({ error: { message: "Not found", type: "invalid_request_error" } });
50
+ }
51
+
52
  const config = getConfig();
53
  const fp = getFingerprint();
54
 
src/translation/openai-to-codex.ts CHANGED
@@ -55,6 +55,7 @@ export function translateToCodexRequest(
55
  input,
56
  stream: true,
57
  store: false,
 
58
  };
59
 
60
  // Add reasoning effort if applicable
 
55
  input,
56
  stream: true,
57
  store: false,
58
+ tools: [],
59
  };
60
 
61
  // Add reasoning effort if applicable
src/update-checker.ts ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Update checker — polls the Codex Sparkle appcast for new versions.
3
+ * Integrates with the main server process.
4
+ */
5
+
6
+ import { readFileSync, writeFileSync, mkdirSync } from "fs";
7
+ import { resolve } from "path";
8
+ import yaml from "js-yaml";
9
+
10
+ const CONFIG_PATH = resolve(process.cwd(), "config/default.yaml");
11
+ const STATE_PATH = resolve(process.cwd(), "data/update-state.json");
12
+ const APPCAST_URL = "https://persistent.oaistatic.com/codex-app-prod/appcast.xml";
13
+ const POLL_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
14
+
15
+ export interface UpdateState {
16
+ last_check: string;
17
+ latest_version: string | null;
18
+ latest_build: string | null;
19
+ download_url: string | null;
20
+ update_available: boolean;
21
+ current_version: string;
22
+ current_build: string;
23
+ }
24
+
25
+ let _currentState: UpdateState | null = null;
26
+ let _pollTimer: ReturnType<typeof setInterval> | null = null;
27
+
28
+ function loadCurrentConfig(): { app_version: string; build_number: string } {
29
+ const raw = yaml.load(readFileSync(CONFIG_PATH, "utf-8")) as Record<string, unknown>;
30
+ const client = raw.client as Record<string, string>;
31
+ return {
32
+ app_version: client.app_version,
33
+ build_number: client.build_number,
34
+ };
35
+ }
36
+
37
+ function parseAppcast(xml: string): {
38
+ version: string | null;
39
+ build: string | null;
40
+ downloadUrl: string | null;
41
+ } {
42
+ const itemMatch = xml.match(/<item>([\s\S]*?)<\/item>/i);
43
+ if (!itemMatch) return { version: null, build: null, downloadUrl: null };
44
+ const item = itemMatch[1];
45
+ const versionMatch = item.match(/sparkle:shortVersionString="([^"]+)"/);
46
+ const buildMatch = item.match(/sparkle:version="([^"]+)"/);
47
+ const urlMatch = item.match(/url="([^"]+)"/);
48
+ return {
49
+ version: versionMatch?.[1] ?? null,
50
+ build: buildMatch?.[1] ?? null,
51
+ downloadUrl: urlMatch?.[1] ?? null,
52
+ };
53
+ }
54
+
55
+ export async function checkForUpdate(): Promise<UpdateState> {
56
+ const current = loadCurrentConfig();
57
+ const res = await fetch(APPCAST_URL);
58
+ if (!res.ok) {
59
+ throw new Error(`Failed to fetch appcast: ${res.status} ${res.statusText}`);
60
+ }
61
+ const xml = await res.text();
62
+ const { version, build, downloadUrl } = parseAppcast(xml);
63
+
64
+ const updateAvailable = !!(version && build &&
65
+ (version !== current.app_version || build !== current.build_number));
66
+
67
+ const state: UpdateState = {
68
+ last_check: new Date().toISOString(),
69
+ latest_version: version,
70
+ latest_build: build,
71
+ download_url: downloadUrl,
72
+ update_available: updateAvailable,
73
+ current_version: current.app_version,
74
+ current_build: current.build_number,
75
+ };
76
+
77
+ _currentState = state;
78
+
79
+ // Persist state
80
+ try {
81
+ mkdirSync(resolve(process.cwd(), "data"), { recursive: true });
82
+ writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
83
+ } catch {
84
+ // best-effort persistence
85
+ }
86
+
87
+ if (updateAvailable) {
88
+ console.log(
89
+ `[UpdateChecker] *** UPDATE AVAILABLE: v${version} (build ${build}) — current: v${current.app_version} (build ${current.build_number})`,
90
+ );
91
+ }
92
+
93
+ return state;
94
+ }
95
+
96
+ /** Get the most recent update check state. */
97
+ export function getUpdateState(): UpdateState | null {
98
+ return _currentState;
99
+ }
100
+
101
+ /**
102
+ * Start periodic update checking.
103
+ * Runs an initial check immediately (non-blocking), then polls every 30 minutes.
104
+ */
105
+ export function startUpdateChecker(): void {
106
+ // Initial check (non-blocking)
107
+ checkForUpdate().catch((err) => {
108
+ console.warn(`[UpdateChecker] Initial check failed: ${err instanceof Error ? err.message : err}`);
109
+ });
110
+
111
+ // Periodic polling
112
+ _pollTimer = setInterval(() => {
113
+ checkForUpdate().catch((err) => {
114
+ console.warn(`[UpdateChecker] Poll failed: ${err instanceof Error ? err.message : err}`);
115
+ });
116
+ }, POLL_INTERVAL_MS);
117
+
118
+ // Don't keep the process alive just for update checks
119
+ if (_pollTimer.unref) _pollTimer.unref();
120
+ }
121
+
122
+ /** Stop the periodic update checker. */
123
+ export function stopUpdateChecker(): void {
124
+ if (_pollTimer) {
125
+ clearInterval(_pollTimer);
126
+ _pollTimer = null;
127
+ }
128
+ }