Spaces:
Paused
feat: architecture audit — disguise hardening, update automation, robustness
Browse filesDisguise 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 +13 -1
- config/models.yaml +76 -0
- scripts/apply-update.ts +6 -4
- scripts/extract-fingerprint.ts +14 -0
- src/auth/account-pool.ts +8 -3
- src/auth/refresh-scheduler.ts +18 -10
- src/config.ts +1 -0
- src/fingerprint/manager.ts +40 -9
- src/index.ts +15 -2
- src/proxy/codex-api.ts +1 -1
- src/routes/chat.ts +25 -1
- src/routes/models.ts +17 -94
- src/routes/web.ts +21 -4
- src/translation/openai-to-codex.ts +1 -0
- src/update-checker.ts +128 -0
|
@@ -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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
|
@@ -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"
|
|
@@ -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, "
|
| 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 —
|
| 123 |
-
const
|
| 124 |
-
|
|
|
|
|
|
|
| 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));
|
|
@@ -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;
|
|
@@ -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 |
-
/**
|
| 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 |
-
|
| 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 |
|
|
@@ -86,16 +86,24 @@ export class RefreshScheduler {
|
|
| 86 |
console.log(`[RefreshScheduler] Refreshing account ${entryId} (${entry.email ?? "?"})`);
|
| 87 |
this.pool.markStatus(entryId, "refreshing");
|
| 88 |
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|
|
@@ -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>;
|
|
@@ -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
|
| 17 |
|
| 18 |
-
|
| 19 |
|
| 20 |
const acctId = accountId ?? extractChatGptAccountId(token);
|
| 21 |
-
if (acctId)
|
| 22 |
|
| 23 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
-
return
|
| 32 |
}
|
| 33 |
|
| 34 |
export function buildHeadersWithContentType(
|
| 35 |
token: string,
|
| 36 |
accountId?: string | null,
|
| 37 |
): Record<string, string> {
|
| 38 |
-
const
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
| 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 |
}
|
|
@@ -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 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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();
|
|
@@ -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 =
|
|
@@ -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");
|
|
@@ -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 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 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.
|
|
@@ -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 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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
|
|
@@ -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 |
+
}
|