codex-endpoint-switcher 1.6.1 → 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,6 +68,7 @@ codex-switcher remove-access
68
68
  - `sync-server`:启动账号同步服务,服务端使用 SQLite 单文件数据库
69
69
  - `install-access`:创建桌面快捷方式和开机启动项
70
70
  - `remove-access`:移除桌面快捷方式和开机启动项
71
+ - 启动器现在会优先记录并复用自己的 PID 文件,关闭和重启比之前更稳
71
72
 
72
73
  ## 网页地址
73
74
 
@@ -142,6 +143,8 @@ codex-switcher sync-server
142
143
 
143
144
  - 拉取前会自动备份本机 `~/.codex/endpoint-presets.json` 和 `~/.codex/endpoint-switcher-state.json`
144
145
  - 服务端数据库是 SQLite,部署只需要一个 Node 进程和一个 `.db` 文件
146
+ - `/api/auth/register` 和 `/api/auth/login` 已内置限流与失败冻结,默认会在连续错误后短时封禁
147
+ - Windows 客户端会优先用 DPAPI 加密本地同步令牌,不再把登录 token 明文写进 `~/.codex/cloud-sync-config.json`
145
148
 
146
149
  ## 版本更新提示
147
150
 
@@ -149,7 +152,7 @@ codex-switcher sync-server
149
152
  - 如果你发布了新版本,其他用户打开控制台后会看到“发现新版本”的提示
150
153
  - 用户可以直接点 `立即更新`,程序会自动关闭、更新并重新打开
151
154
  - 也可以继续使用“复制升级命令”手动更新
152
- - 上一次自动更新的成功或失败结果会记录到本地,下次打开控制台会显示结果
155
+ - 自动更新成功记录只会提示一次,失败记录会保留,方便排查问题
153
156
 
154
157
  也可以用 CLI 手动检查:
155
158
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-endpoint-switcher",
3
- "version": "1.6.1",
3
+ "version": "1.6.3",
4
4
  "description": "用于切换 Codex URL 和 Key 的本地网页控制台与 npm CLI",
5
5
  "main": "src/main/main.js",
6
6
  "bin": {
@@ -3,6 +3,8 @@ const { spawn } = require("node:child_process");
3
3
 
4
4
  const WAIT_PARENT_EXIT_TIMEOUT_MS = 20000;
5
5
  const WAIT_PARENT_POLL_MS = 400;
6
+ const INSTALL_RETRY_TIMES = 3;
7
+ const INSTALL_RETRY_BASE_DELAY_MS = 1500;
6
8
 
7
9
  function sleep(timeoutMs) {
8
10
  return new Promise((resolve) => {
@@ -38,9 +40,10 @@ function runProcess(command, args, options = {}) {
38
40
  stderr += chunk.toString("utf8");
39
41
  });
40
42
 
41
- child.on("exit", (code) => {
43
+ child.on("exit", (code, signal) => {
42
44
  resolve({
43
- code: Number(code || 0),
45
+ code: normalizeExitCode(code),
46
+ signal: signal || "",
44
47
  stdout,
45
48
  stderr,
46
49
  });
@@ -56,6 +59,78 @@ function runProcess(command, args, options = {}) {
56
59
  });
57
60
  }
58
61
 
62
+ function normalizeExitCode(code) {
63
+ const safeCode = Number(code || 0);
64
+ if (!Number.isFinite(safeCode)) {
65
+ return 0;
66
+ }
67
+
68
+ if (safeCode > 0x7fffffff) {
69
+ return safeCode - 0x100000000;
70
+ }
71
+
72
+ return safeCode;
73
+ }
74
+
75
+ function isBusyInstallFailure(result) {
76
+ const combined = `${result.stdout || ""}\n${result.stderr || ""}`.toUpperCase();
77
+ return (
78
+ result.code === -4082 ||
79
+ combined.includes("EBUSY") ||
80
+ combined.includes("RESOURCE BUSY OR LOCKED") ||
81
+ combined.includes("EPERM") ||
82
+ combined.includes("OPERATION NOT PERMITTED")
83
+ );
84
+ }
85
+
86
+ async function installLatestPackage(payload) {
87
+ let lastResult = null;
88
+
89
+ for (let attempt = 1; attempt <= INSTALL_RETRY_TIMES; attempt += 1) {
90
+ if (attempt > 1) {
91
+ await appendLog(
92
+ payload.logPath,
93
+ `[${new Date().toISOString()}] 检测到文件占用,开始第 ${attempt} 次重试安装。\n`,
94
+ );
95
+ await sleep(INSTALL_RETRY_BASE_DELAY_MS * attempt);
96
+ }
97
+
98
+ const result = await runProcess(
99
+ payload.nodePath,
100
+ [payload.npmCliPath, "install", "-g", `${payload.packageName}@latest`],
101
+ {
102
+ cwd: process.cwd(),
103
+ env: process.env,
104
+ },
105
+ );
106
+
107
+ if (result.stdout) {
108
+ await appendLog(payload.logPath, result.stdout);
109
+ }
110
+ if (result.stderr) {
111
+ await appendLog(payload.logPath, result.stderr);
112
+ }
113
+
114
+ lastResult = result;
115
+ if (result.code === 0) {
116
+ return result;
117
+ }
118
+
119
+ if (!isBusyInstallFailure(result) || attempt >= INSTALL_RETRY_TIMES) {
120
+ return result;
121
+ }
122
+ }
123
+
124
+ return (
125
+ lastResult || {
126
+ code: 1,
127
+ signal: "",
128
+ stdout: "",
129
+ stderr: "自动更新安装没有返回结果。",
130
+ }
131
+ );
132
+ }
133
+
59
134
  function isProcessAlive(pid) {
60
135
  try {
61
136
  process.kill(pid, 0);
@@ -145,25 +220,13 @@ async function main() {
145
220
  await sleep(Number(payload.waitSeconds || 3) * 1000);
146
221
  await waitForProcessExit(Number(payload.parentPid || 0), payload.logPath);
147
222
 
148
- const installResult = await runProcess(
149
- payload.nodePath,
150
- [payload.npmCliPath, "install", "-g", `${payload.packageName}@latest`],
151
- {
152
- cwd: process.cwd(),
153
- env: process.env,
154
- },
155
- );
156
-
157
- if (installResult.stdout) {
158
- await appendLog(payload.logPath, installResult.stdout);
159
- }
160
- if (installResult.stderr) {
161
- await appendLog(payload.logPath, installResult.stderr);
162
- }
223
+ const installResult = await installLatestPackage(payload);
163
224
 
164
225
  if (installResult.code !== 0) {
165
226
  const finishedAt = new Date().toISOString();
166
- const errorMessage = `npm 安装失败,退出码:${installResult.code}`;
227
+ const errorMessage = isBusyInstallFailure(installResult)
228
+ ? `npm 安装失败:文件仍被占用,请稍后重试或先手动关闭占用进程。退出码:${installResult.code}`
229
+ : `npm 安装失败,退出码:${installResult.code}`;
167
230
  await appendLog(payload.logPath, `[${finishedAt}] ${errorMessage}\n`);
168
231
  await writeState(payload.statePath, {
169
232
  status: "failed",
@@ -1,10 +1,15 @@
1
1
  const fs = require("node:fs/promises");
2
2
  const path = require("node:path");
3
3
  const os = require("node:os");
4
+ const crypto = require("node:crypto");
5
+ const { execFile } = require("node:child_process");
6
+ const { promisify } = require("node:util");
4
7
  const profileManager = require("./profile-manager");
5
8
 
6
9
  const codexRoot = path.join(os.homedir(), ".codex");
7
10
  const cloudSyncConfigPath = path.join(codexRoot, "cloud-sync-config.json");
11
+ const cloudSyncKeyPath = path.join(codexRoot, "cloud-sync.key");
12
+ const execFileAsync = promisify(execFile);
8
13
 
9
14
  function normalizeServerUrl(value) {
10
15
  const raw = String(value || "").trim();
@@ -80,6 +85,8 @@ async function ensureStorage() {
80
85
  serverUrl: getDefaultServerUrl(),
81
86
  username: "",
82
87
  authToken: "",
88
+ authTokenProtection: "",
89
+ authTokenProtected: "",
83
90
  tokenExpiresAt: "",
84
91
  lastPushAt: "",
85
92
  lastPullAt: "",
@@ -92,15 +99,177 @@ async function ensureStorage() {
92
99
  }
93
100
  }
94
101
 
102
+ async function runPowerShell(command, env = {}) {
103
+ const result = await execFileAsync(
104
+ "powershell.exe",
105
+ [
106
+ "-NoProfile",
107
+ "-NonInteractive",
108
+ "-ExecutionPolicy",
109
+ "Bypass",
110
+ "-Command",
111
+ command,
112
+ ],
113
+ {
114
+ windowsHide: true,
115
+ env: {
116
+ ...process.env,
117
+ ...env,
118
+ },
119
+ maxBuffer: 1024 * 1024,
120
+ },
121
+ );
122
+
123
+ return String(result.stdout || "").trim();
124
+ }
125
+
126
+ async function protectWithWindowsDpapi(plainText) {
127
+ const payload = Buffer.from(String(plainText || ""), "utf8").toString("base64");
128
+ return runPowerShell(
129
+ [
130
+ "Add-Type -AssemblyName System.Security",
131
+ "$plainBytes = [Convert]::FromBase64String($env:CODEX_SWITCHER_PLAIN_BASE64)",
132
+ "$cipherBytes = [System.Security.Cryptography.ProtectedData]::Protect($plainBytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)",
133
+ "[Convert]::ToBase64String($cipherBytes)",
134
+ ].join("; "),
135
+ {
136
+ CODEX_SWITCHER_PLAIN_BASE64: payload,
137
+ },
138
+ );
139
+ }
140
+
141
+ async function unprotectWithWindowsDpapi(cipherText) {
142
+ return runPowerShell(
143
+ [
144
+ "Add-Type -AssemblyName System.Security",
145
+ "$cipherBytes = [Convert]::FromBase64String($env:CODEX_SWITCHER_CIPHER_BASE64)",
146
+ "$plainBytes = [System.Security.Cryptography.ProtectedData]::Unprotect($cipherBytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)",
147
+ "[System.Text.Encoding]::UTF8.GetString($plainBytes)",
148
+ ].join("; "),
149
+ {
150
+ CODEX_SWITCHER_CIPHER_BASE64: String(cipherText || "").trim(),
151
+ },
152
+ );
153
+ }
154
+
155
+ async function ensureLocalAesKey() {
156
+ await ensureStorage();
157
+
158
+ try {
159
+ const stored = await fs.readFile(cloudSyncKeyPath, "utf8");
160
+ const normalized = stored.trim();
161
+ if (normalized) {
162
+ return Buffer.from(normalized, "base64");
163
+ }
164
+ } catch {
165
+ // 首次写入时继续生成本地密钥。
166
+ }
167
+
168
+ const key = crypto.randomBytes(32);
169
+ await fs.writeFile(cloudSyncKeyPath, `${key.toString("base64")}\n`, "utf8");
170
+ return key;
171
+ }
172
+
173
+ async function protectWithLocalAes(plainText) {
174
+ const key = await ensureLocalAesKey();
175
+ const iv = crypto.randomBytes(12);
176
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
177
+ const encrypted = Buffer.concat([cipher.update(String(plainText || ""), "utf8"), cipher.final()]);
178
+ const tag = cipher.getAuthTag();
179
+ return Buffer.concat([iv, tag, encrypted]).toString("base64");
180
+ }
181
+
182
+ async function unprotectWithLocalAes(cipherText) {
183
+ const key = await ensureLocalAesKey();
184
+ const payload = Buffer.from(String(cipherText || "").trim(), "base64");
185
+ if (payload.length < 29) {
186
+ throw new Error("本地同步令牌数据不完整。");
187
+ }
188
+
189
+ const iv = payload.subarray(0, 12);
190
+ const tag = payload.subarray(12, 28);
191
+ const encrypted = payload.subarray(28);
192
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
193
+ decipher.setAuthTag(tag);
194
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
195
+ }
196
+
197
+ async function protectToken(plainText) {
198
+ const value = String(plainText || "").trim();
199
+ if (!value) {
200
+ return {
201
+ protection: "",
202
+ protectedValue: "",
203
+ };
204
+ }
205
+
206
+ if (process.platform === "win32") {
207
+ try {
208
+ return {
209
+ protection: "dpapi",
210
+ protectedValue: await protectWithWindowsDpapi(value),
211
+ };
212
+ } catch {
213
+ // Windows DPAPI 不可用时回退到本地 AES,避免登录态明文落盘。
214
+ }
215
+ }
216
+
217
+ return {
218
+ protection: "aes-256-gcm",
219
+ protectedValue: await protectWithLocalAes(value),
220
+ };
221
+ }
222
+
223
+ async function unprotectToken(config) {
224
+ const protection = String(config.authTokenProtection || "").trim();
225
+ const protectedValue = String(config.authTokenProtected || "").trim();
226
+ const legacyToken = String(config.authToken || "").trim();
227
+
228
+ if (protectedValue) {
229
+ if (protection === "dpapi") {
230
+ return {
231
+ authToken: await unprotectWithWindowsDpapi(protectedValue),
232
+ hasStoredToken: true,
233
+ tokenError: "",
234
+ };
235
+ }
236
+
237
+ if (protection === "aes-256-gcm") {
238
+ return {
239
+ authToken: await unprotectWithLocalAes(protectedValue),
240
+ hasStoredToken: true,
241
+ tokenError: "",
242
+ };
243
+ }
244
+
245
+ return {
246
+ authToken: "",
247
+ hasStoredToken: true,
248
+ tokenError: "本地同步令牌保护方式无法识别,请重新登录。",
249
+ };
250
+ }
251
+
252
+ return {
253
+ authToken: legacyToken,
254
+ hasStoredToken: Boolean(legacyToken),
255
+ tokenError: "",
256
+ };
257
+ }
258
+
95
259
  async function readConfig() {
96
260
  await ensureStorage();
97
261
  const content = await fs.readFile(cloudSyncConfigPath, "utf8");
98
262
  const config = JSON.parse(content);
263
+ const tokenInfo = await unprotectToken(config);
99
264
 
100
265
  return {
101
266
  serverUrl: String(config.serverUrl || getDefaultServerUrl()).trim(),
102
267
  username: String(config.username || "").trim(),
103
- authToken: String(config.authToken || "").trim(),
268
+ authToken: String(tokenInfo.authToken || "").trim(),
269
+ hasStoredToken: Boolean(tokenInfo.hasStoredToken),
270
+ authTokenProtection: String(config.authTokenProtection || "").trim(),
271
+ authTokenProtected: String(config.authTokenProtected || "").trim(),
272
+ tokenError: String(tokenInfo.tokenError || "").trim(),
104
273
  tokenExpiresAt: String(config.tokenExpiresAt || "").trim(),
105
274
  lastPushAt: String(config.lastPushAt || "").trim(),
106
275
  lastPullAt: String(config.lastPullAt || "").trim(),
@@ -109,7 +278,25 @@ async function readConfig() {
109
278
 
110
279
  async function writeConfig(config) {
111
280
  await ensureStorage();
112
- await fs.writeFile(cloudSyncConfigPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
281
+ const tokenPayload = await protectToken(config.authToken);
282
+ await fs.writeFile(
283
+ cloudSyncConfigPath,
284
+ `${JSON.stringify(
285
+ {
286
+ serverUrl: String(config.serverUrl || getDefaultServerUrl()).trim(),
287
+ username: String(config.username || "").trim(),
288
+ authToken: "",
289
+ authTokenProtection: tokenPayload.protection,
290
+ authTokenProtected: tokenPayload.protectedValue,
291
+ tokenExpiresAt: String(config.tokenExpiresAt || "").trim(),
292
+ lastPushAt: String(config.lastPushAt || "").trim(),
293
+ lastPullAt: String(config.lastPullAt || "").trim(),
294
+ },
295
+ null,
296
+ 2,
297
+ )}\n`,
298
+ "utf8",
299
+ );
113
300
  }
114
301
 
115
302
  async function requestJson(serverUrl, pathname, options = {}) {
@@ -150,14 +337,14 @@ async function getCloudSyncStatus() {
150
337
  configPath: cloudSyncConfigPath,
151
338
  serverUrl: config.serverUrl,
152
339
  username: config.username,
153
- hasToken: Boolean(config.authToken),
340
+ hasToken: Boolean(config.hasStoredToken),
154
341
  loggedIn: false,
155
342
  tokenExpiresAt: config.tokenExpiresAt,
156
343
  lastPushAt: config.lastPushAt,
157
344
  lastPullAt: config.lastPullAt,
158
345
  remoteUser: "",
159
346
  remoteProfileUpdatedAt: "",
160
- lastError: "",
347
+ lastError: config.tokenError || "",
161
348
  };
162
349
 
163
350
  if (!config.serverUrl || !config.authToken) {
@@ -252,6 +439,10 @@ async function logoutAccount() {
252
439
 
253
440
  async function requireLoggedInConfig() {
254
441
  const config = await readConfig();
442
+ if (config.tokenError) {
443
+ throw new Error(config.tokenError);
444
+ }
445
+
255
446
  if (!config.authToken) {
256
447
  throw new Error("当前还没有登录同步账号。");
257
448
  }
@@ -559,23 +559,54 @@ async function updateEndpoint(id, payload) {
559
559
  }
560
560
 
561
561
  const endpoint = validateEndpointPayload(payload);
562
- const endpoints = await readEndpointStore();
562
+ const [endpoints, currentState, state] = await Promise.all([
563
+ readEndpointStore(),
564
+ readCurrentConfigAndAuth(),
565
+ readEndpointState(),
566
+ ]);
563
567
  const index = endpoints.findIndex((item) => item.id === safeId);
564
568
 
565
569
  if (index === -1) {
566
570
  throw new Error("未找到要更新的端点。");
567
571
  }
568
572
 
569
- endpoints[index] = {
570
- ...endpoints[index],
573
+ const previousEndpoint = endpoints[index];
574
+ const nextEndpoint = {
575
+ ...previousEndpoint,
571
576
  note: endpoint.note,
572
577
  url: endpoint.url,
573
578
  key: endpoint.key,
574
579
  updatedAt: new Date().toISOString(),
575
580
  };
581
+ endpoints[index] = nextEndpoint;
582
+
583
+ const shouldSyncDirectConfig = Boolean(
584
+ !currentState.proxyModeEnabled && state.activeEndpointId === safeId && currentState.provider,
585
+ );
586
+
587
+ let backupPaths = {};
588
+ if (shouldSyncDirectConfig) {
589
+ const nextConfigContent = updateProviderBaseUrl(
590
+ currentState.configContent,
591
+ currentState.provider,
592
+ nextEndpoint.url,
593
+ );
594
+ const nextAuthConfig = {
595
+ ...currentState.authConfig,
596
+ OPENAI_API_KEY: nextEndpoint.key,
597
+ };
598
+
599
+ backupPaths = await backupCurrentState();
600
+ await writeTextFile(mainConfigPath, nextConfigContent);
601
+ await writeJsonFile(authConfigPath, nextAuthConfig);
602
+ }
576
603
 
577
604
  await writeEndpointStore(endpoints);
578
- return buildEndpointResponse(endpoints[index], "");
605
+ return {
606
+ ...backupPaths,
607
+ syncedCurrentConfig: shouldSyncDirectConfig,
608
+ ...buildEndpointResponse(nextEndpoint, ""),
609
+ };
579
610
  }
580
611
 
581
612
  async function deleteEndpoint(id) {
@@ -592,19 +623,28 @@ async function deleteEndpoint(id) {
592
623
  }
593
624
 
594
625
  const [removed] = endpoints.splice(index, 1);
595
- await writeEndpointStore(endpoints);
596
-
597
626
  const state = await readEndpointState();
598
- if (state.activeEndpointId === removed.id) {
599
- await writeEndpointState({
600
- ...state,
601
- activeEndpointId: "",
602
- });
627
+ const deletingActiveEndpoint = state.activeEndpointId === removed.id;
628
+
629
+ if (state.proxyModeEnabled && deletingActiveEndpoint && endpoints.length === 0) {
630
+ throw new Error("当前正在使用这条热更新连接,至少保留一条连接后再删除。");
631
+ }
632
+
633
+ let nextActiveEndpointId = state.activeEndpointId;
634
+ if (deletingActiveEndpoint) {
635
+ nextActiveEndpointId = endpoints[0]?.id || "";
603
636
  }
604
637
 
638
+ await writeEndpointStore(endpoints);
639
+ await writeEndpointState({
640
+ ...state,
641
+ activeEndpointId: nextActiveEndpointId,
642
+ });
643
+
605
644
  return {
606
645
  id: removed.id,
607
646
  note: removed.note,
647
+ nextActiveEndpointId,
608
648
  };
609
649
  }
610
650
 
@@ -26,6 +26,36 @@ function getAutoUpdateStatePath() {
26
26
  return path.join(stateDir, "codex-switcher-auto-update.json");
27
27
  }
28
28
 
29
+ function getAutoUpdateRuntimeDir() {
30
+ const runtimeDir = path.join(os.homedir(), ".codex", "runtime");
31
+ fs.mkdirSync(runtimeDir, { recursive: true });
32
+ return runtimeDir;
33
+ }
34
+
35
+ function cleanupStagedAutoUpdateRunners(runtimeDir) {
36
+ const staleBeforeMs = Date.now() - 24 * 60 * 60 * 1000;
37
+
38
+ for (const entry of fs.readdirSync(runtimeDir, { withFileTypes: true })) {
39
+ if (
40
+ !entry.isFile() ||
41
+ !entry.name.startsWith("codex-switcher-auto-update-runner-") ||
42
+ !entry.name.endsWith(".js")
43
+ ) {
44
+ continue;
45
+ }
46
+
47
+ const fullPath = path.join(runtimeDir, entry.name);
48
+ try {
49
+ const stats = fs.statSync(fullPath);
50
+ if (stats.mtimeMs < staleBeforeMs) {
51
+ fs.unlinkSync(fullPath);
52
+ }
53
+ } catch {
54
+ // 文件被占用或刚被其他进程删除时忽略。
55
+ }
56
+ }
57
+ }
58
+
29
59
  function readAutoUpdateState() {
30
60
  try {
31
61
  const content = fs.readFileSync(getAutoUpdateStatePath(), "utf8");
@@ -44,6 +74,35 @@ function writeAutoUpdateState(payload) {
44
74
  );
45
75
  }
46
76
 
77
+ function clearAutoUpdateState() {
78
+ try {
79
+ fs.unlinkSync(getAutoUpdateStatePath());
80
+ } catch {
81
+ // 状态文件不存在时忽略。
82
+ }
83
+ }
84
+
85
+ function consumeAutoUpdateState() {
86
+ const state = readAutoUpdateState();
87
+ if (!state) {
88
+ return null;
89
+ }
90
+
91
+ if (state.status === "succeeded") {
92
+ if (state.reportedAt) {
93
+ clearAutoUpdateState();
94
+ return null;
95
+ }
96
+
97
+ writeAutoUpdateState({
98
+ ...state,
99
+ reportedAt: new Date().toISOString(),
100
+ });
101
+ }
102
+
103
+ return state;
104
+ }
105
+
47
106
  function resolveRuntimePaths() {
48
107
  const nodePath = process.execPath;
49
108
  const nodeDir = path.dirname(nodePath);
@@ -71,6 +130,17 @@ function resolveRuntimePaths() {
71
130
  };
72
131
  }
73
132
 
133
+ function stageAutoUpdateRunner() {
134
+ const runtimeDir = getAutoUpdateRuntimeDir();
135
+ cleanupStagedAutoUpdateRunners(runtimeDir);
136
+ const stagedRunnerPath = path.join(
137
+ runtimeDir,
138
+ `codex-switcher-auto-update-runner-${Date.now()}.js`,
139
+ );
140
+ fs.copyFileSync(runnerEntryPath, stagedRunnerPath);
141
+ return stagedRunnerPath;
142
+ }
143
+
74
144
  function sleep(timeoutMs) {
75
145
  return new Promise((resolve) => {
76
146
  setTimeout(resolve, timeoutMs);
@@ -109,7 +179,7 @@ function buildStatus(payload = {}) {
109
179
  const currentVersion = packageInfo.version;
110
180
  const latestVersion = payload.latestVersion || currentVersion;
111
181
  const hasUpdate = compareVersions(latestVersion, currentVersion) > 0;
112
- const autoUpdateState = payload.autoUpdateState || readAutoUpdateState();
182
+ const autoUpdateState = payload.autoUpdateState || consumeAutoUpdateState();
113
183
 
114
184
  return {
115
185
  packageName: packageInfo.name,
@@ -188,6 +258,7 @@ function scheduleAutoUpdate(options = {}) {
188
258
  const logPath = getAutoUpdateLogPath();
189
259
  const statePath = getAutoUpdateStatePath();
190
260
  const runtimePaths = resolveRuntimePaths();
261
+ const stagedRunnerPath = stageAutoUpdateRunner();
191
262
 
192
263
  writeAutoUpdateState({
193
264
  status: "scheduled",
@@ -217,7 +288,7 @@ function scheduleAutoUpdate(options = {}) {
217
288
  "utf8",
218
289
  ).toString("base64url");
219
290
 
220
- const child = spawn(process.execPath, [runnerEntryPath, payload], {
291
+ const child = spawn(process.execPath, [stagedRunnerPath, payload], {
221
292
  cwd: os.homedir(),
222
293
  detached: true,
223
294
  stdio: "ignore",
@@ -237,6 +308,7 @@ function scheduleAutoUpdate(options = {}) {
237
308
  }
238
309
 
239
310
  module.exports = {
311
+ clearAutoUpdateState,
240
312
  getUpdateStatus,
241
313
  scheduleAutoUpdate,
242
314
  };
@@ -724,15 +724,21 @@ async function handleSubmitEndpoint(event) {
724
724
  }
725
725
 
726
726
  try {
727
+ let result;
727
728
  if (id) {
728
- await bridge.updateEndpoint({ id, note, url, key });
729
+ result = await bridge.updateEndpoint({ id, note, url, key });
729
730
  } else {
730
- await bridge.createEndpoint({ note, url, key });
731
+ result = await bridge.createEndpoint({ note, url, key });
731
732
  }
732
733
 
733
734
  await refreshDashboard(false);
734
735
  closeEndpointModal();
735
736
  resetForm();
737
+ if (id && result?.syncedCurrentConfig) {
738
+ setStatus(`已更新连接:${note},并同步到当前 Codex 配置。`, "success");
739
+ return;
740
+ }
741
+
736
742
  setStatus(id ? `已更新连接:${note}` : `已新增连接:${note}`, "success");
737
743
  } catch (error) {
738
744
  if (isUnauthorizedError(error)) {
@@ -10,6 +10,12 @@ const defaultDbPath = path.join(defaultRoot, "cloud-sync.db");
10
10
  const defaultHost = process.env.SYNC_SERVER_HOST || "127.0.0.1";
11
11
  const defaultPort = Number(process.env.SYNC_SERVER_PORT || 3190);
12
12
  const sessionTtlDays = Number(process.env.SYNC_SERVER_SESSION_DAYS || 30);
13
+ const authWindowMs = Number(process.env.SYNC_SERVER_AUTH_WINDOW_MS || 10 * 60 * 1000);
14
+ const authBlockMs = Number(process.env.SYNC_SERVER_AUTH_BLOCK_MS || 15 * 60 * 1000);
15
+ const authMaxAttemptsPerIp = Number(process.env.SYNC_SERVER_AUTH_IP_ATTEMPTS || 18);
16
+ const authMaxAttemptsPerAccount = Number(process.env.SYNC_SERVER_AUTH_ACCOUNT_ATTEMPTS || 10);
17
+ const authMaxFailuresPerIp = Number(process.env.SYNC_SERVER_AUTH_IP_FAILURES || 10);
18
+ const authMaxFailuresPerAccount = Number(process.env.SYNC_SERVER_AUTH_ACCOUNT_FAILURES || 5);
13
19
 
14
20
  function wrapAsync(handler) {
15
21
  return async (req, res, next) => {
@@ -30,6 +36,132 @@ function addDaysToNow(days) {
30
36
  return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
31
37
  }
32
38
 
39
+ function getClientIp(req) {
40
+ const forwardedFor = String(req.headers["x-forwarded-for"] || "").trim();
41
+ const firstForwarded = forwardedFor.split(",")[0]?.trim();
42
+ const rawIp = firstForwarded || req.socket?.remoteAddress || "unknown";
43
+ return rawIp.replace(/^::ffff:/, "");
44
+ }
45
+
46
+ function toAccountKey(username) {
47
+ return String(username || "").trim().toLowerCase() || "__anonymous__";
48
+ }
49
+
50
+ function formatBlockMessage(scopeLabel, blockedUntil) {
51
+ const seconds = Math.max(1, Math.ceil((blockedUntil - Date.now()) / 1000));
52
+ return `${scopeLabel}尝试过于频繁,请在 ${seconds} 秒后重试。`;
53
+ }
54
+
55
+ function createAttemptBucket() {
56
+ return {
57
+ attempts: [],
58
+ failures: [],
59
+ blockedUntil: 0,
60
+ updatedAt: 0,
61
+ };
62
+ }
63
+
64
+ function pruneBucket(bucket, now) {
65
+ bucket.attempts = bucket.attempts.filter((value) => now - value < authWindowMs);
66
+ bucket.failures = bucket.failures.filter((value) => now - value < authWindowMs);
67
+ bucket.updatedAt = now;
68
+ }
69
+
70
+ function markBlocked(bucket, now) {
71
+ bucket.blockedUntil = Math.max(bucket.blockedUntil || 0, now + authBlockMs);
72
+ }
73
+
74
+ function cleanupAttemptMap(store, now) {
75
+ for (const [key, bucket] of store.entries()) {
76
+ pruneBucket(bucket, now);
77
+ if (
78
+ (!bucket.attempts.length && !bucket.failures.length && bucket.blockedUntil <= now) ||
79
+ now - bucket.updatedAt > authWindowMs * 3
80
+ ) {
81
+ store.delete(key);
82
+ }
83
+ }
84
+ }
85
+
86
+ function createAuthRateGuard() {
87
+ const ipBuckets = new Map();
88
+ const accountBuckets = new Map();
89
+
90
+ function getBucket(store, key, now) {
91
+ if (!store.has(key)) {
92
+ store.set(key, createAttemptBucket());
93
+ }
94
+
95
+ const bucket = store.get(key);
96
+ pruneBucket(bucket, now);
97
+ return bucket;
98
+ }
99
+
100
+ function assertBucketAllowed(bucket, scopeLabel, maxAttempts, now) {
101
+ if (bucket.blockedUntil > now) {
102
+ const error = new Error(formatBlockMessage(scopeLabel, bucket.blockedUntil));
103
+ error.statusCode = 429;
104
+ throw error;
105
+ }
106
+
107
+ if (bucket.attempts.length >= maxAttempts) {
108
+ markBlocked(bucket, now);
109
+ const error = new Error(formatBlockMessage(scopeLabel, bucket.blockedUntil));
110
+ error.statusCode = 429;
111
+ throw error;
112
+ }
113
+ }
114
+
115
+ function recordAttempt(bucket, now) {
116
+ bucket.attempts.push(now);
117
+ bucket.updatedAt = now;
118
+ }
119
+
120
+ function recordFailure(bucket, maxFailures, now) {
121
+ bucket.failures.push(now);
122
+ bucket.updatedAt = now;
123
+ if (bucket.failures.length >= maxFailures) {
124
+ markBlocked(bucket, now);
125
+ }
126
+ }
127
+
128
+ function recordSuccess(bucket, now) {
129
+ bucket.failures = [];
130
+ bucket.blockedUntil = 0;
131
+ bucket.updatedAt = now;
132
+ }
133
+
134
+ return {
135
+ assertAllowed(req, username) {
136
+ const now = Date.now();
137
+ const ipKey = getClientIp(req);
138
+ const accountKey = toAccountKey(username);
139
+ const ipBucket = getBucket(ipBuckets, ipKey, now);
140
+ const accountBucket = getBucket(accountBuckets, accountKey, now);
141
+
142
+ assertBucketAllowed(ipBucket, "当前 IP ", authMaxAttemptsPerIp, now);
143
+ assertBucketAllowed(accountBucket, "当前账号", authMaxAttemptsPerAccount, now);
144
+
145
+ recordAttempt(ipBucket, now);
146
+ recordAttempt(accountBucket, now);
147
+ cleanupAttemptMap(ipBuckets, now);
148
+ cleanupAttemptMap(accountBuckets, now);
149
+ },
150
+ recordFailure(req, username) {
151
+ const now = Date.now();
152
+ const ipBucket = getBucket(ipBuckets, getClientIp(req), now);
153
+ const accountBucket = getBucket(accountBuckets, toAccountKey(username), now);
154
+ recordFailure(ipBucket, authMaxFailuresPerIp, now);
155
+ recordFailure(accountBucket, authMaxFailuresPerAccount, now);
156
+ },
157
+ recordSuccess(req, username) {
158
+ const now = Date.now();
159
+ recordSuccess(getBucket(ipBuckets, getClientIp(req), now), now);
160
+ recordSuccess(getBucket(accountBuckets, toAccountKey(username), now), now);
161
+ },
162
+ };
163
+ }
164
+
33
165
  function normalizeUsername(value) {
34
166
  const username = String(value || "").trim();
35
167
  if (!username) {
@@ -220,6 +352,7 @@ function createCloudSyncApp(options = {}) {
220
352
  const host = options.host || defaultHost;
221
353
  const port = Number(options.port || defaultPort);
222
354
  const db = createDatabase(dbPath);
355
+ const authRateGuard = createAuthRateGuard();
223
356
  const authRequired = buildAuthMiddleware(db);
224
357
  const app = express();
225
358
 
@@ -240,62 +373,80 @@ function createCloudSyncApp(options = {}) {
240
373
  app.post(
241
374
  "/api/auth/register",
242
375
  wrapAsync(async (req) => {
243
- const username = normalizeUsername(req.body.username);
244
- const password = normalizePassword(req.body.password);
245
- const existing = getUserByUsername(db, username);
246
-
247
- if (existing) {
248
- const error = new Error("这个账号已经存在,请直接登录。");
249
- error.statusCode = 409;
376
+ const usernameCandidate = String(req.body.username || "").trim();
377
+ authRateGuard.assertAllowed(req, usernameCandidate);
378
+
379
+ try {
380
+ const username = normalizeUsername(usernameCandidate);
381
+ const password = normalizePassword(req.body.password);
382
+ const existing = getUserByUsername(db, username);
383
+
384
+ if (existing) {
385
+ const error = new Error("这个账号已经存在,请直接登录。");
386
+ error.statusCode = 409;
387
+ throw error;
388
+ }
389
+
390
+ const salt = randomBytes(16).toString("base64url");
391
+ const createdAt = nowIso();
392
+ const inserted = db
393
+ .prepare(
394
+ `
395
+ INSERT INTO users (username, password_salt, password_hash, created_at, updated_at)
396
+ VALUES (?, ?, ?, ?, ?)
397
+ `,
398
+ )
399
+ .run(username, salt, hashPassword(password, salt), createdAt, createdAt);
400
+ const userId = Number(inserted.lastInsertRowid);
401
+ const session = createSession(db, userId);
402
+ authRateGuard.recordSuccess(req, username);
403
+
404
+ return {
405
+ token: session.token,
406
+ expiresAt: session.expiresAt,
407
+ user: {
408
+ id: userId,
409
+ username,
410
+ },
411
+ };
412
+ } catch (error) {
413
+ authRateGuard.recordFailure(req, usernameCandidate);
250
414
  throw error;
251
415
  }
252
-
253
- const salt = randomBytes(16).toString("base64url");
254
- const createdAt = nowIso();
255
- const inserted = db
256
- .prepare(
257
- `
258
- INSERT INTO users (username, password_salt, password_hash, created_at, updated_at)
259
- VALUES (?, ?, ?, ?, ?)
260
- `,
261
- )
262
- .run(username, salt, hashPassword(password, salt), createdAt, createdAt);
263
- const userId = Number(inserted.lastInsertRowid);
264
- const session = createSession(db, userId);
265
-
266
- return {
267
- token: session.token,
268
- expiresAt: session.expiresAt,
269
- user: {
270
- id: userId,
271
- username,
272
- },
273
- };
274
416
  }),
275
417
  );
276
418
 
277
419
  app.post(
278
420
  "/api/auth/login",
279
421
  wrapAsync(async (req) => {
280
- const username = normalizeUsername(req.body.username);
281
- const password = normalizePassword(req.body.password);
282
- const user = getUserByUsername(db, username);
283
-
284
- if (!user || !verifyPassword(password, user.passwordSalt, user.passwordHash)) {
285
- const error = new Error("账号或密码不正确。");
286
- error.statusCode = 401;
422
+ const usernameCandidate = String(req.body.username || "").trim();
423
+ authRateGuard.assertAllowed(req, usernameCandidate);
424
+
425
+ try {
426
+ const username = normalizeUsername(usernameCandidate);
427
+ const password = normalizePassword(req.body.password);
428
+ const user = getUserByUsername(db, username);
429
+
430
+ if (!user || !verifyPassword(password, user.passwordSalt, user.passwordHash)) {
431
+ const error = new Error("账号或密码不正确。");
432
+ error.statusCode = 401;
433
+ throw error;
434
+ }
435
+
436
+ const session = createSession(db, user.id);
437
+ authRateGuard.recordSuccess(req, username);
438
+ return {
439
+ token: session.token,
440
+ expiresAt: session.expiresAt,
441
+ user: {
442
+ id: user.id,
443
+ username: user.username,
444
+ },
445
+ };
446
+ } catch (error) {
447
+ authRateGuard.recordFailure(req, usernameCandidate);
287
448
  throw error;
288
449
  }
289
-
290
- const session = createSession(db, user.id);
291
- return {
292
- token: session.token,
293
- expiresAt: session.expiresAt,
294
- user: {
295
- id: user.id,
296
- username: user.username,
297
- },
298
- };
299
450
  }),
300
451
  );
301
452
 
@@ -1,7 +1,7 @@
1
1
  const fs = require("node:fs");
2
2
  const path = require("node:path");
3
3
  const http = require("node:http");
4
- const { exec, spawn } = require("node:child_process");
4
+ const { exec, execFile, spawn } = require("node:child_process");
5
5
 
6
6
  const projectRoot = path.resolve(__dirname, "../..");
7
7
  const runtimeDir = path.join(projectRoot, "runtime");
@@ -12,11 +12,44 @@ const healthUrl = `http://127.0.0.1:${port}/api/health`;
12
12
  const closeUrl = `http://127.0.0.1:${port}/api/app/close`;
13
13
  const proxyHealthUrl = `http://127.0.0.1:${proxyPort}/__switcher/health`;
14
14
  const consoleUrl = `http://localhost:${port}`;
15
+ const pidFilePath = path.join(runtimeDir, "web-server.pid.json");
15
16
 
16
17
  function ensureRuntimeDir() {
17
18
  fs.mkdirSync(runtimeDir, { recursive: true });
18
19
  }
19
20
 
21
+ function readPidFile() {
22
+ try {
23
+ const content = fs.readFileSync(pidFilePath, "utf8");
24
+ const parsed = JSON.parse(content);
25
+ return parsed && typeof parsed === "object" ? parsed : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function writePidFile(payload) {
32
+ ensureRuntimeDir();
33
+ fs.writeFileSync(pidFilePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
34
+ }
35
+
36
+ function clearPidFile() {
37
+ try {
38
+ fs.unlinkSync(pidFilePath);
39
+ } catch {
40
+ // 文件不存在时忽略。
41
+ }
42
+ }
43
+
44
+ function isProcessAlive(pid) {
45
+ try {
46
+ process.kill(pid, 0);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
20
53
  function requestJson(url, timeoutMs = 1200) {
21
54
  return new Promise((resolve, reject) => {
22
55
  const request = http.get(url, (response) => {
@@ -133,11 +166,98 @@ function spawnServerInBackground() {
133
166
  });
134
167
 
135
168
  child.unref();
169
+ writePidFile({
170
+ pid: child.pid,
171
+ createdAt: new Date().toISOString(),
172
+ nodePath: process.execPath,
173
+ serverEntryPath,
174
+ port,
175
+ proxyPort,
176
+ });
136
177
  return child.pid;
137
178
  }
138
179
 
180
+ function inspectProcessCommandLine(pid) {
181
+ return new Promise((resolve) => {
182
+ execFile(
183
+ "powershell.exe",
184
+ [
185
+ "-NoProfile",
186
+ "-NonInteractive",
187
+ "-ExecutionPolicy",
188
+ "Bypass",
189
+ "-Command",
190
+ `& { $p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($null -eq $p) { '' } else { $p.CommandLine } }`,
191
+ ],
192
+ {
193
+ windowsHide: true,
194
+ },
195
+ (error, stdout) => {
196
+ if (error) {
197
+ resolve("");
198
+ return;
199
+ }
200
+
201
+ resolve(String(stdout || "").trim());
202
+ },
203
+ );
204
+ });
205
+ }
206
+
207
+ async function isManagedServerPid(pid) {
208
+ if (!Number.isFinite(pid) || pid <= 0 || !isProcessAlive(pid)) {
209
+ return false;
210
+ }
211
+
212
+ const commandLine = await inspectProcessCommandLine(pid);
213
+ if (!commandLine) {
214
+ return false;
215
+ }
216
+
217
+ return commandLine.includes(serverEntryPath);
218
+ }
219
+
220
+ async function stopTrackedServerProcess() {
221
+ const pidInfo = readPidFile();
222
+ const pid = Number(pidInfo?.pid || 0);
223
+ if (!(await isManagedServerPid(pid))) {
224
+ clearPidFile();
225
+ return false;
226
+ }
227
+
228
+ await new Promise((resolve, reject) => {
229
+ exec(`cmd /c taskkill /PID ${pid} /T /F`, (error) => {
230
+ if (error) {
231
+ reject(error);
232
+ return;
233
+ }
234
+
235
+ resolve();
236
+ });
237
+ });
238
+
239
+ clearPidFile();
240
+ return true;
241
+ }
242
+
139
243
  async function ensureServerRunning() {
140
244
  if (await checkServerHealth()) {
245
+ const pidInfo = readPidFile();
246
+ const trackedPid = Number(pidInfo?.pid || 0);
247
+ if (!(await isManagedServerPid(trackedPid))) {
248
+ const listeningPid = await getListeningPid();
249
+ if (await isManagedServerPid(listeningPid)) {
250
+ writePidFile({
251
+ pid: listeningPid,
252
+ createdAt: pidInfo?.createdAt || new Date().toISOString(),
253
+ nodePath: process.execPath,
254
+ serverEntryPath,
255
+ port,
256
+ proxyPort,
257
+ });
258
+ }
259
+ }
260
+
141
261
  return {
142
262
  started: false,
143
263
  running: true,
@@ -150,6 +270,7 @@ async function ensureServerRunning() {
150
270
  const ready = await waitForServer();
151
271
 
152
272
  if (!ready) {
273
+ clearPidFile();
153
274
  throw new Error("本地网页服务启动失败,请检查 runtime 日志。");
154
275
  }
155
276
 
@@ -204,21 +325,8 @@ function getListeningPid() {
204
325
  }
205
326
 
206
327
  async function restartServer() {
207
- const pid = await getListeningPid();
208
- if (pid) {
209
- await new Promise((resolve, reject) => {
210
- exec(`cmd /c taskkill /PID ${pid} /F`, (error) => {
211
- if (error) {
212
- reject(error);
213
- return;
214
- }
215
-
216
- resolve();
217
- });
218
- });
219
- await sleep(800);
220
- }
221
-
328
+ await stopServer();
329
+ await sleep(600);
222
330
  return ensureServerRunning();
223
331
  }
224
332
 
@@ -269,11 +377,14 @@ async function stopServer() {
269
377
  await sleep(1000);
270
378
  }
271
379
 
272
- const webKilled = await killListeningPid(port);
273
- const proxyKilled = await killListeningPid(proxyPort);
380
+ const trackedKilled = await stopTrackedServerProcess().catch(() => false);
381
+ const webKilled = trackedKilled ? true : await killListeningPid(port);
382
+ const proxyKilled = trackedKilled ? true : await killListeningPid(proxyPort);
383
+ clearPidFile();
274
384
 
275
385
  return {
276
386
  stopped: true,
387
+ trackedKilled,
277
388
  webKilled,
278
389
  proxyKilled,
279
390
  port,