codexmate 0.0.33 → 0.0.36

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.
Files changed (47) hide show
  1. package/cli/agents-files.js +6 -0
  2. package/cli/archive-helpers.js +11 -4
  3. package/cli/local-bridge.js +9 -4
  4. package/cli/openai-bridge.js +1 -1
  5. package/cli/update.js +11 -2
  6. package/cli.js +133 -64
  7. package/lib/cli-webhook.js +29 -1
  8. package/package.json +2 -1
  9. package/web-ui/app.js +37 -2
  10. package/web-ui/index.html +2 -1
  11. package/web-ui/logic.claude.mjs +4 -0
  12. package/web-ui/logic.sessions.mjs +6 -5
  13. package/web-ui/modules/app.computed.dashboard.mjs +4 -0
  14. package/web-ui/modules/app.computed.session.mjs +147 -6
  15. package/web-ui/modules/app.methods.claude-config.mjs +41 -0
  16. package/web-ui/modules/app.methods.codex-config.mjs +11 -3
  17. package/web-ui/modules/app.methods.navigation.mjs +32 -2
  18. package/web-ui/modules/app.methods.session-browser.mjs +12 -6
  19. package/web-ui/modules/app.methods.session-trash.mjs +30 -0
  20. package/web-ui/modules/app.methods.startup-claude.mjs +9 -0
  21. package/web-ui/modules/app.methods.webhook.mjs +8 -0
  22. package/web-ui/modules/i18n.dict.mjs +8 -0
  23. package/web-ui/modules/sessions-filters-url.mjs +65 -12
  24. package/web-ui/modules/skills.methods.mjs +31 -0
  25. package/web-ui/partials/index/layout-header.html +17 -12
  26. package/web-ui/partials/index/modal-webhook.html +42 -0
  27. package/web-ui/partials/index/modals-basic.html +50 -0
  28. package/web-ui/partials/index/panel-config-claude.html +13 -22
  29. package/web-ui/partials/index/panel-config-codex.html +8 -22
  30. package/web-ui/partials/index/panel-market.html +76 -149
  31. package/web-ui/partials/index/panel-sessions.html +2 -2
  32. package/web-ui/partials/index/panel-settings.html +119 -149
  33. package/web-ui/partials/index/panel-usage.html +115 -68
  34. package/web-ui/res/vue.runtime.global.prod.js +7 -0
  35. package/web-ui/res/web-ui-render.precompiled.js +7274 -0
  36. package/web-ui/session-helpers.mjs +15 -4
  37. package/web-ui/source-bundle.cjs +73 -1
  38. package/web-ui/styles/base-theme.css +10 -0
  39. package/web-ui/styles/bridge-pool.css +69 -0
  40. package/web-ui/styles/layout-shell.css +66 -27
  41. package/web-ui/styles/navigation-panels.css +8 -0
  42. package/web-ui/styles/responsive.css +50 -9
  43. package/web-ui/styles/sessions-usage.css +336 -319
  44. package/web-ui/styles/settings-panel.css +300 -234
  45. package/web-ui/styles/skills-market.css +294 -0
  46. package/web-ui/styles/titles-cards.css +14 -0
  47. package/web-ui/styles/webhook.css +38 -4
@@ -92,6 +92,9 @@ function createAgentsFileController(deps = {}) {
92
92
  function applyClaudeMdFile(params = {}) {
93
93
  const filePath = resolveClaudeMdFilePath();
94
94
  const content = typeof params.content === 'string' ? params.content : '';
95
+ if (content.length > 2 * 1024 * 1024) {
96
+ return { error: '内容过大(最大 2MB)' };
97
+ }
95
98
  const lineEnding = params.lineEnding === '\r\n' ? '\r\n' : '\n';
96
99
  const normalized = normalizeLineEnding(content, lineEnding);
97
100
  const finalContent = ensureUtf8Bom(normalized);
@@ -150,6 +153,9 @@ function createAgentsFileController(deps = {}) {
150
153
  }
151
154
 
152
155
  const content = typeof params.content === 'string' ? params.content : '';
156
+ if (content.length > 2 * 1024 * 1024) {
157
+ return { error: '内容过大(最大 2MB)' };
158
+ }
153
159
  const lineEnding = params.lineEnding === '\r\n' ? '\r\n' : '\n';
154
160
  const normalized = normalizeLineEnding(content, lineEnding);
155
161
  const finalContent = ensureUtf8Bom(normalized);
@@ -4,6 +4,7 @@ function createArchiveHelperController(deps = {}) {
4
4
  path,
5
5
  os,
6
6
  execSync,
7
+ execFileSync,
7
8
  zipLib,
8
9
  yauzl,
9
10
  ensureDir,
@@ -341,8 +342,11 @@ function createArchiveHelperController(deps = {}) {
341
342
 
342
343
  const zipTool = resolveZipTool();
343
344
  if (zipTool.type === 'zip') {
344
- const cmd = `"${zipTool.cmd}" -0 -q -r "${zipFilePath}" "${dirPath}"`;
345
- execSync(cmd, { stdio: 'ignore' });
345
+ if (typeof execFileSync === 'function') {
346
+ execFileSync(zipTool.cmd, ['-0', '-q', '-r', zipFilePath, dirPath], { stdio: 'ignore' });
347
+ } else {
348
+ execSync(`"${zipTool.cmd}" -0 -q -r "${zipFilePath}" "${dirPath}"`, { stdio: 'ignore' });
349
+ }
346
350
  } else {
347
351
  await zipWithLibrary(dirPath, zipFilePath);
348
352
  }
@@ -371,8 +375,11 @@ function createArchiveHelperController(deps = {}) {
371
375
 
372
376
  try {
373
377
  if (zipTool.type === 'zip') {
374
- const cmd = `"${zipTool.cmd}" -0 -q -r "${zipFilePath}" "${dirPath}"`;
375
- execSync(cmd, { stdio: 'ignore' });
378
+ if (typeof execFileSync === 'function') {
379
+ execFileSync(zipTool.cmd, ['-0', '-q', '-r', zipFilePath, dirPath], { stdio: 'ignore' });
380
+ } else {
381
+ execSync(`"${zipTool.cmd}" -0 -q -r "${zipFilePath}" "${dirPath}"`, { stdio: 'ignore' });
382
+ }
376
383
  } else {
377
384
  await zipWithLibrary(dirPath, zipFilePath);
378
385
  }
@@ -74,7 +74,7 @@ function buildClaudeUpstreamPool(claudeProvidersFile, excludedProviders) {
74
74
  if (excludedSet.has(name.toLowerCase())) continue;
75
75
  const baseUrl = typeof p.baseUrl === 'string' ? p.baseUrl.trim() : '';
76
76
  if (!baseUrl || !isValidHttpUrl(normalizeBaseUrl(baseUrl))) continue;
77
- pool.push({ name, baseUrl: normalizeBaseUrl(baseUrl), apiKey: typeof p.apiKey === 'string' ? p.apiKey : '' });
77
+ pool.push({ name, baseUrl: normalizeBaseUrl(baseUrl), apiKey: typeof p.apiKey === 'string' ? p.apiKey : '', model: typeof p.model === 'string' ? p.model.trim() : '' });
78
78
  }
79
79
  if (pool.length === 0) return { error: '请先添加可用的 Claude 上游提供商' };
80
80
  return { pool };
@@ -271,7 +271,7 @@ function createLocalBridgeHttpHandler(options = {}) {
271
271
  return;
272
272
  }
273
273
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
274
- res.end(JSON.stringify({ object: 'codexmate.claude_local_bridge', provider: entry.name, status: 'ok', pool: pool.map(p => p.name) }));
274
+ res.end(JSON.stringify({ object: 'codexmate.claude_local_bridge', provider: entry.name, model: entry.model || '', status: 'ok', pool: pool.map(p => p.name) }));
275
275
  return;
276
276
  }
277
277
 
@@ -285,7 +285,12 @@ function createLocalBridgeHttpHandler(options = {}) {
285
285
 
286
286
  let parsedBody;
287
287
  try { parsedBody = bodyResult.body ? JSON.parse(bodyResult.body) : {}; } catch (_) { parsedBody = {}; }
288
+ // Override model to match the selected upstream provider
289
+ if (entry.model && parsedBody && typeof parsedBody === 'object') {
290
+ parsedBody.model = entry.model;
291
+ }
288
292
  const wantsStream = !!(parsedBody && parsedBody.stream);
293
+ const bodyToForward = JSON.stringify(parsedBody);
289
294
  const upstreamUrl = joinApiUrl(entry.baseUrl.replace(/\/+$/, ''), suffix);
290
295
  const headers = { 'Content-Type': 'application/json' };
291
296
  if (entry.apiKey) {
@@ -300,7 +305,7 @@ function createLocalBridgeHttpHandler(options = {}) {
300
305
  // Streaming proxy: pipe upstream SSE directly to client
301
306
  const upstreamResult = await streamClaudeUpstream(upstreamUrl, {
302
307
  method: req.method || 'POST',
303
- body: bodyResult.body,
308
+ body: bodyToForward,
304
309
  headers,
305
310
  maxBytes: maxUpstreamBytes,
306
311
  httpAgent,
@@ -322,7 +327,7 @@ function createLocalBridgeHttpHandler(options = {}) {
322
327
  // Non-streaming proxy
323
328
  const upstreamResult = await retryTransientRequest(() => proxyRequestJson(upstreamUrl, {
324
329
  method: req.method || 'POST',
325
- body: bodyResult.body || null,
330
+ body: bodyToForward || null,
326
331
  headers,
327
332
  maxBytes: maxUpstreamBytes,
328
333
  httpAgent,
@@ -5,7 +5,7 @@ const { StringDecoder } = require('string_decoder');
5
5
  const { readJsonFile, writeJsonAtomic } = require('../lib/cli-file-utils');
6
6
  const { isValidHttpUrl, normalizeBaseUrl, joinApiUrl } = require('../lib/cli-utils');
7
7
 
8
- const DEFAULT_BRIDGE_TOKEN = 'codexmate';
8
+ const DEFAULT_BRIDGE_TOKEN = crypto.randomBytes(16).toString('hex');
9
9
  const SETTINGS_VERSION = 1;
10
10
  // 推理模型 reasoning 阶段可能长时间无字节输出,需匹配 codex 的 stream_idle_timeout_ms=300000。
11
11
  const STREAM_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
package/cli/update.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const os = require('os');
3
4
  const https = require('https');
4
5
  const { execSync, spawn } = require('child_process');
5
6
 
@@ -147,8 +148,16 @@ function updateViaStandalone(version) {
147
148
  if (process.platform !== 'win32') {
148
149
  console.log('[Update] 尝试自动执行安装脚本...');
149
150
  try {
150
- const script = 'curl -fsSL https://raw.githubusercontent.com/SakuraByteCore/codexmate/main/scripts/install.sh | bash';
151
- execSync(script, { stdio: 'inherit' });
151
+ const crypto = require('crypto');
152
+ const tmpScript = path.join(os.tmpdir(), `codexmate-install-${Date.now()}.sh`);
153
+ const { execFileSync, execSync: _unused } = require('child_process');
154
+ execSync(`curl -fsSL -o "${tmpScript}" https://raw.githubusercontent.com/SakuraByteCore/codexmate/main/scripts/install.sh`, { stdio: 'inherit' });
155
+ const scriptContent = fs.readFileSync(tmpScript, 'utf-8');
156
+ const checksum = crypto.createHash('sha256').update(scriptContent).digest('hex');
157
+ console.log(`[Update] Script checksum: ${checksum}`);
158
+ fs.chmodSync(tmpScript, 0o755);
159
+ execFileSync('bash', [tmpScript], { stdio: 'inherit' });
160
+ try { fs.unlinkSync(tmpScript); } catch (_) {}
152
161
  } catch (e) {
153
162
  console.warn('[Update] 自动脚本执行失败,请手动运行。');
154
163
  }
package/cli.js CHANGED
@@ -7,7 +7,7 @@ const toml = require('@iarna/toml');
7
7
  const JSON5 = require('json5');
8
8
  const zipLib = require('zip-lib');
9
9
  const yauzl = require('yauzl');
10
- const { exec, execSync, spawn, spawnSync } = require('child_process');
10
+ const { exec, execSync, execFileSync, spawn, spawnSync } = require('child_process');
11
11
  const http = require('http');
12
12
  const https = require('https');
13
13
  const net = require('net');
@@ -175,7 +175,7 @@ const {
175
175
  } = require('./lib/download-artifacts');
176
176
 
177
177
  const DEFAULT_WEB_PORT = 3737;
178
- const DEFAULT_WEB_HOST = '0.0.0.0';
178
+ const DEFAULT_WEB_HOST = '127.0.0.1';
179
179
  const DEFAULT_WEB_OPEN_HOST = '127.0.0.1';
180
180
 
181
181
  // ============================================================================
@@ -758,7 +758,7 @@ function updateAuthJson(apiKey) {
758
758
  } catch (e) { }
759
759
  }
760
760
  authData['OPENAI_API_KEY'] = apiKey;
761
- fs.writeFileSync(AUTH_FILE, JSON.stringify(authData, null, 2), 'utf-8');
761
+ fs.writeFileSync(AUTH_FILE, JSON.stringify(authData, null, 2), { encoding: 'utf-8', mode: 0o600 });
762
762
  }
763
763
 
764
764
  function isPlainObject(value) {
@@ -1711,6 +1711,7 @@ const {
1711
1711
  path,
1712
1712
  os,
1713
1713
  execSync,
1714
+ execFileSync,
1714
1715
  zipLib,
1715
1716
  yauzl,
1716
1717
  ensureDir,
@@ -4234,6 +4235,9 @@ function parseClaudeSessionSummary(filePath, options = {}) {
4234
4235
 
4235
4236
  const tailRecords = parseJsonlTailRecords(filePath, summaryReadBytes);
4236
4237
  for (const record of tailRecords) {
4238
+ if (record && record.timestamp) {
4239
+ updatedAt = updateLatestIso(updatedAt, record.timestamp);
4240
+ }
4237
4241
  applySessionUsageSummaryFromRecord(usageState, record, 'claude');
4238
4242
  totalTokens = usageState.totalTokens || 0;
4239
4243
  contextWindow = usageState.contextWindow || 0;
@@ -4741,8 +4745,8 @@ function listClaudeSessions(limit, options = {}) {
4741
4745
  continue;
4742
4746
  }
4743
4747
 
4744
- const updatedAt = toIsoTime(entry.modified || entry.fileMtime, '');
4745
- const createdAt = toIsoTime(entry.created, '');
4748
+ let updatedAt = toIsoTime(entry.modified || entry.fileMtime, fileStat.mtime.toISOString());
4749
+ let createdAt = toIsoTime(entry.created, '');
4746
4750
  let title = truncateText(entry.summary || entry.firstPrompt || sessionId, 120);
4747
4751
  let messageCount = Number.isFinite(entry.messageCount) ? Math.max(0, entry.messageCount - 1) : 0;
4748
4752
  let totalTokens = 0;
@@ -4774,6 +4778,12 @@ function listClaudeSessions(limit, options = {}) {
4774
4778
 
4775
4779
  const quickMessages = [];
4776
4780
  for (const record of quickRecords) {
4781
+ if (record && record.timestamp) {
4782
+ if (!createdAt) {
4783
+ createdAt = toIsoTime(record.timestamp, createdAt);
4784
+ }
4785
+ updatedAt = updateLatestIso(updatedAt, record.timestamp);
4786
+ }
4777
4787
  applySessionUsageSummaryFromRecord(usageState, record, 'claude');
4778
4788
  const recordModels = readSessionModelsFromRecord(record);
4779
4789
  for (const recordModel of recordModels) {
@@ -4804,6 +4814,12 @@ function listClaudeSessions(limit, options = {}) {
4804
4814
 
4805
4815
  const tailRecords = parseJsonlTailRecords(filePath, summaryReadBytes);
4806
4816
  for (const record of tailRecords) {
4817
+ if (record && record.timestamp) {
4818
+ if (!createdAt) {
4819
+ createdAt = toIsoTime(record.timestamp, createdAt);
4820
+ }
4821
+ updatedAt = updateLatestIso(updatedAt, record.timestamp);
4822
+ }
4807
4823
  applySessionUsageSummaryFromRecord(usageState, record, 'claude');
4808
4824
  const recordModels = readSessionModelsFromRecord(record);
4809
4825
  for (const recordModel of recordModels) {
@@ -4863,27 +4879,29 @@ function listClaudeSessions(limit, options = {}) {
4863
4879
  }
4864
4880
  }
4865
4881
 
4866
- if (sessions.length === 0) {
4867
- const fallbackFiles = collectRecentJsonlFiles(claudeProjectsDir, {
4868
- returnCount: scanCount,
4869
- maxFilesScanned,
4870
- ignoreSubPath: `${path.sep}subagents${path.sep}`
4882
+ // 补充扫描未索引的 .jsonl 文件(包括 sessions-index.json 中遗漏的会话)
4883
+ const seenFilePaths = new Set(sessions.map((item) => item.filePath).filter(Boolean));
4884
+ const fallbackFiles = collectRecentJsonlFiles(claudeProjectsDir, {
4885
+ returnCount: scanCount,
4886
+ maxFilesScanned,
4887
+ ignoreSubPath: `${path.sep}subagents${path.sep}`
4888
+ });
4889
+ for (const filePath of fallbackFiles) {
4890
+ if (seenFilePaths.has(filePath)) continue;
4891
+ const summary = parseClaudeSessionSummary(filePath, {
4892
+ summaryReadBytes,
4893
+ titleReadBytes
4871
4894
  });
4872
- for (const filePath of fallbackFiles) {
4873
- const summary = parseClaudeSessionSummary(filePath, {
4874
- summaryReadBytes,
4875
- titleReadBytes
4876
- });
4877
- if (summary) {
4878
- sessions.push(attachSessionNativeStatus({
4879
- ...summary,
4880
- derived: isDerivedSessionFile(filePath)
4881
- }));
4882
- }
4895
+ if (summary) {
4896
+ sessions.push(attachSessionNativeStatus({
4897
+ ...summary,
4898
+ derived: isDerivedSessionFile(filePath)
4899
+ }));
4900
+ seenFilePaths.add(filePath);
4901
+ }
4883
4902
 
4884
- if (sessions.length >= targetCount) {
4885
- break;
4886
- }
4903
+ if (sessions.length >= targetCount) {
4904
+ break;
4887
4905
  }
4888
4906
  }
4889
4907
 
@@ -7892,7 +7910,7 @@ function normalizeImportPayload(payload) {
7892
7910
  const name = item.name || item.provider || '';
7893
7911
  const baseUrl = item.baseUrl || item.base_url || item.url || '';
7894
7912
  const apiKey = item.apiKey ?? item.key ?? item.preferred_auth_method ?? null;
7895
- if (name && baseUrl) {
7913
+ if (name && baseUrl && /^[a-zA-Z0-9_\-.\s]+$/.test(name)) {
7896
7914
  providers[name] = { baseUrl, apiKey };
7897
7915
  }
7898
7916
  }
@@ -7901,7 +7919,7 @@ function normalizeImportPayload(payload) {
7901
7919
  if (!item || typeof item !== 'object') continue;
7902
7920
  const baseUrl = item.baseUrl || item.base_url || item.url || '';
7903
7921
  const apiKey = item.apiKey ?? item.key ?? item.preferred_auth_method ?? null;
7904
- if (name && baseUrl) {
7922
+ if (name && baseUrl && /^[a-zA-Z0-9_\-.\s]+$/.test(name)) {
7905
7923
  providers[name] = { baseUrl, apiKey };
7906
7924
  }
7907
7925
  }
@@ -9237,6 +9255,9 @@ function applyClaudeSettingsRaw(params = {}) {
9237
9255
  if (!content.trim()) {
9238
9256
  return { error: '内容不能为空' };
9239
9257
  }
9258
+ if (content.length > 1024 * 1024) {
9259
+ return { error: '内容过大(最大 1MB)' };
9260
+ }
9240
9261
  let parsed;
9241
9262
  try {
9242
9263
  parsed = JSON.parse(content);
@@ -10039,9 +10060,10 @@ function watchPathsForRestart(targets, onChange) {
10039
10060
  }
10040
10061
  // #endregion watchPathsForRestart
10041
10062
 
10042
- function writeJsonResponse(res, statusCode, payload) {
10063
+ function writeJsonResponse(res, statusCode, payload, headers = {}) {
10043
10064
  const body = JSON.stringify(payload, null, 2);
10044
10065
  res.writeHead(statusCode, {
10066
+ ...headers,
10045
10067
  'Content-Type': 'application/json; charset=utf-8',
10046
10068
  'Content-Length': Buffer.byteLength(body, 'utf-8')
10047
10069
  });
@@ -10119,7 +10141,7 @@ function assertRequestAuthorized(req, res) {
10119
10141
  return { ok: false, mode: 'missing-token' };
10120
10142
  }
10121
10143
  const actual = extractRequestToken(req);
10122
- if (!actual || actual !== expected) {
10144
+ if (!actual || !safeTimingEqual(actual, expected)) {
10123
10145
  writeJsonResponse(res, 401, { error: 'Unauthorized' });
10124
10146
  return { ok: false, mode: 'unauthorized' };
10125
10147
  }
@@ -10576,7 +10598,38 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
10576
10598
  res.end('Internal Server Error');
10577
10599
  };
10578
10600
 
10601
+ const rateLimitMap = new Map();
10602
+ const RATE_LIMIT_WINDOW_MS = 60000;
10603
+ const RATE_LIMIT_MAX = 120;
10604
+ function checkRateLimit(key) {
10605
+ const now = Date.now();
10606
+ const entry = rateLimitMap.get(key);
10607
+ if (!entry || now - entry.start > RATE_LIMIT_WINDOW_MS) {
10608
+ rateLimitMap.set(key, { start: now, count: 1 });
10609
+ return true;
10610
+ }
10611
+ entry.count++;
10612
+ if (entry.count > RATE_LIMIT_MAX) return false;
10613
+ return true;
10614
+ }
10615
+ setInterval(function () {
10616
+ const now = Date.now();
10617
+ for (const [key, entry] of rateLimitMap.entries()) {
10618
+ if (now - entry.start > RATE_LIMIT_WINDOW_MS * 2) rateLimitMap.delete(key);
10619
+ }
10620
+ }, RATE_LIMIT_WINDOW_MS).unref();
10621
+
10579
10622
  const server = http.createServer((req, res) => {
10623
+ const securityHeaders = {
10624
+ 'X-Content-Type-Options': 'nosniff',
10625
+ 'X-Frame-Options': 'DENY',
10626
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:"
10627
+ };
10628
+ const origWriteHead = res.writeHead.bind(res);
10629
+ res.writeHead = function (statusCode, headers) {
10630
+ const merged = Object.assign({}, securityHeaders, headers || {});
10631
+ return origWriteHead(statusCode, merged);
10632
+ };
10580
10633
  const requestPath = (req.url || '/').split('?')[0];
10581
10634
  const sendJson = (statusCode, payload) => {
10582
10635
  const body = JSON.stringify(payload || {}, null, 2);
@@ -10599,28 +10652,15 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
10599
10652
  || requestPath.startsWith('/download/')
10600
10653
  ) {
10601
10654
  const remoteAddr = req && req.socket ? req.socket.remoteAddress : '';
10602
- const isLoopback = !remoteAddr
10603
- || remoteAddr === '127.0.0.1'
10604
- || remoteAddr === '::1'
10605
- || remoteAddr === '::ffff:127.0.0.1';
10655
+ const isLoopback = !remoteAddr || isLoopbackRemoteAddress(remoteAddr);
10606
10656
  if (!isLoopback) {
10607
- const expected = typeof process.env.CODEXMATE_HTTP_TOKEN === 'string'
10608
- ? process.env.CODEXMATE_HTTP_TOKEN.trim()
10609
- : '';
10610
- if (!expected) {
10611
- sendJson(403, {
10612
- error: 'Remote access is disabled (set CODEXMATE_HTTP_TOKEN or use --host 127.0.0.1)'
10613
- });
10657
+ const rateLimitKey = (remoteAddr || 'unknown') + ':' + requestPath;
10658
+ if (!checkRateLimit(rateLimitKey)) {
10659
+ writeJsonResponse(res, 429, { error: 'Rate limit exceeded' }, { 'Retry-After': '60' });
10614
10660
  return;
10615
10661
  }
10616
- const headers = req && req.headers && typeof req.headers === 'object' ? req.headers : {};
10617
- const rawAuth = typeof headers.authorization === 'string' ? headers.authorization.trim() : '';
10618
- const match = rawAuth ? rawAuth.match(/^bearer\s+(.+)$/i) : null;
10619
- const actual = match && match[1]
10620
- ? match[1].trim()
10621
- : (rawAuth ? rawAuth : (typeof headers['x-codexmate-token'] === 'string' ? String(headers['x-codexmate-token']).trim() : ''));
10622
- if (!actual || actual !== expected) {
10623
- sendJson(401, { error: 'Unauthorized' });
10662
+ const auth = assertRequestAuthorized(req, res);
10663
+ if (!auth.ok) {
10624
10664
  return;
10625
10665
  }
10626
10666
  }
@@ -11352,15 +11392,18 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
11352
11392
  res.end(errorBody, 'utf-8');
11353
11393
  }
11354
11394
  });
11355
- } else if (requestPath === '/web-ui') {
11356
- try {
11357
- const html = readBundledWebUiHtml(htmlPath);
11358
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
11359
- res.end(html);
11360
- } catch (error) {
11361
- writeWebUiAssetError(res, requestPath, error);
11362
- }
11395
+ } else if (requestPath === '/web-ui/index.html') {
11396
+ const rawUrl = typeof req.url === 'string' ? req.url : '';
11397
+ const queryIndex = rawUrl.indexOf('?');
11398
+ const query = queryIndex >= 0 ? rawUrl.slice(queryIndex) : '';
11399
+ res.writeHead(302, {
11400
+ 'Location': `/${query}`,
11401
+ 'Content-Type': 'text/plain; charset=utf-8',
11402
+ 'Cache-Control': 'no-store, max-age=0'
11403
+ });
11404
+ res.end('Found');
11363
11405
  } else if (requestPath.startsWith('/web-ui/')) {
11406
+ // Skip the /web-ui/ directory itself, which is handled above
11364
11407
  const normalized = path.normalize(requestPath).replace(/^([\\.\\/])+/, '');
11365
11408
  const filePath = path.join(__dirname, normalized);
11366
11409
  if (!isPathInside(filePath, webDir)) {
@@ -11369,11 +11412,22 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
11369
11412
  return;
11370
11413
  }
11371
11414
  const relativePath = path.relative(webDir, filePath).replace(/\\/g, '/');
11415
+
11416
+ // Empty relativePath means direct /web-ui/ access - return 404
11417
+ if (relativePath === '' || relativePath === 'index.html') {
11418
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
11419
+ res.end('Not Found');
11420
+ return;
11421
+ }
11422
+
11372
11423
  const dynamicAsset = PUBLIC_WEB_UI_DYNAMIC_ASSETS.get(relativePath);
11373
11424
  if (dynamicAsset) {
11374
11425
  try {
11375
11426
  const assetBody = dynamicAsset.reader(filePath);
11376
- res.writeHead(200, { 'Content-Type': dynamicAsset.mime });
11427
+ res.writeHead(200, {
11428
+ 'Content-Type': dynamicAsset.mime,
11429
+ 'Cache-Control': 'no-store, max-age=0'
11430
+ });
11377
11431
  res.end(assetBody, 'utf-8');
11378
11432
  } catch (error) {
11379
11433
  writeWebUiAssetError(res, requestPath, error);
@@ -11400,7 +11454,10 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
11400
11454
  : ext === '.json'
11401
11455
  ? 'application/json; charset=utf-8'
11402
11456
  : 'application/octet-stream';
11403
- res.writeHead(200, { 'Content-Type': mime });
11457
+ res.writeHead(200, {
11458
+ 'Content-Type': mime,
11459
+ 'Cache-Control': 'no-store, max-age=0'
11460
+ });
11404
11461
  fs.createReadStream(filePath).pipe(res);
11405
11462
  } else if (requestPath.startsWith('/download/')) {
11406
11463
  const fileName = requestPath.slice('/download/'.length);
@@ -11461,15 +11518,27 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
11461
11518
  : ext === '.json'
11462
11519
  ? 'application/json; charset=utf-8'
11463
11520
  : 'application/octet-stream';
11464
- res.writeHead(200, { 'Content-Type': mime });
11521
+ res.writeHead(200, {
11522
+ 'Content-Type': mime,
11523
+ 'Cache-Control': 'no-store, max-age=0'
11524
+ });
11465
11525
  fs.createReadStream(filePath).pipe(res);
11466
11526
  } else {
11467
- try {
11468
- const html = readBundledWebUiHtml(htmlPath);
11469
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
11470
- res.end(html);
11471
- } catch (error) {
11472
- writeWebUiAssetError(res, requestPath, error);
11527
+ // Only serve HTML for root path; /web-ui returns 404.
11528
+ if (requestPath === '/') {
11529
+ try {
11530
+ const html = readBundledWebUiHtml(htmlPath);
11531
+ res.writeHead(200, {
11532
+ 'Content-Type': 'text/html; charset=utf-8',
11533
+ 'Cache-Control': 'no-store, max-age=0'
11534
+ });
11535
+ res.end(html);
11536
+ } catch (error) {
11537
+ writeWebUiAssetError(res, requestPath, error);
11538
+ }
11539
+ } else {
11540
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
11541
+ res.end('Not Found');
11473
11542
  }
11474
11543
  }
11475
11544
  });
@@ -3,10 +3,33 @@ const fs = require('fs');
3
3
  const http = require('http');
4
4
  const https = require('https');
5
5
  const os = require('os');
6
+ const net = require('net');
6
7
 
7
8
  const ALLOWED_EVENTS = ['provider-switch', 'claude-md-edit'];
8
9
  const DEFAULT_TIMEOUT_MS = 5000;
9
10
 
11
+ function isPrivateNetworkHost(hostname) {
12
+ const host = typeof hostname === 'string' ? hostname.trim().toLowerCase() : '';
13
+ if (!host) return true;
14
+ if (host === 'localhost') return true;
15
+ const ipVer = net.isIP(host);
16
+ if (!ipVer) return false;
17
+ if (ipVer === 4) {
18
+ const parts = host.split('.').map(function (x) { return parseInt(x, 10); });
19
+ if (parts[0] === 10) return true;
20
+ if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
21
+ if (parts[0] === 192 && parts[1] === 168) return true;
22
+ if (parts[0] === 127) return true;
23
+ if (parts[0] === 0) return true;
24
+ if (parts[0] === 169 && parts[1] === 254) return true;
25
+ return false;
26
+ }
27
+ if (host === '::1' || host === '::') return true;
28
+ if (host.startsWith('fc') || host.startsWith('fd')) return true;
29
+ if (host.startsWith('fe80')) return true;
30
+ return false;
31
+ }
32
+
10
33
  function defaultConfigPath() {
11
34
  return path.join(os.homedir(), '.codex', 'codexmate-webhook.json');
12
35
  }
@@ -42,7 +65,7 @@ function saveWebhookConfig(cfg, filePath) {
42
65
  try {
43
66
  fs.mkdirSync(path.dirname(target), { recursive: true });
44
67
  } catch (_) {}
45
- fs.writeFileSync(target, JSON.stringify(normalized, null, 2), 'utf-8');
68
+ fs.writeFileSync(target, JSON.stringify(normalized, null, 2), { encoding: 'utf-8', mode: 0o600 });
46
69
  return normalized;
47
70
  }
48
71
 
@@ -55,6 +78,11 @@ function postJson(targetUrl, payload, timeoutMs) {
55
78
  resolve({ ok: false, error: 'invalid-url' });
56
79
  return;
57
80
  }
81
+ const allowPrivateWebhook = process.env.CODEXMATE_ALLOW_PRIVATE_WEBHOOK === '1';
82
+ if (!allowPrivateWebhook && isPrivateNetworkHost(parsed.hostname || '')) {
83
+ resolve({ ok: false, error: 'Refusing to send webhook to private network host' });
84
+ return;
85
+ }
58
86
  const transport = parsed.protocol === 'https:' ? https : http;
59
87
  const body = JSON.stringify(payload || {});
60
88
  let req;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codexmate",
3
- "version": "0.0.33",
3
+ "version": "0.0.36",
4
4
  "description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具",
5
5
  "main": "cli.js",
6
6
  "bin": {
@@ -46,6 +46,7 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@iarna/toml": "^2.2.5",
49
+ "@vue/compiler-dom": "^3.5.30",
49
50
  "json5": "^2.2.3",
50
51
  "yauzl": "^3.2.1",
51
52
  "zip-lib": "^1.2.1"
package/web-ui/app.js CHANGED
@@ -7,8 +7,10 @@ import {
7
7
  import { createAppComputed } from './modules/app.computed.index.mjs';
8
8
  import { createAppMethods } from './modules/app.methods.index.mjs';
9
9
  import { loadConfigTemplateDiffConfirmEnabledFromStorage } from './modules/config-template-confirm-pref.mjs';
10
+ import { installWebUiUrlCanonicalization } from './modules/sessions-filters-url.mjs';
10
11
 
11
12
  document.addEventListener('DOMContentLoaded', () => {
13
+ installWebUiUrlCanonicalization();
12
14
  if (typeof Vue === 'undefined') {
13
15
  console.error('Vue 库未能在 DOMContentLoaded 触发前加载完成。');
14
16
  const fallbackTarget = document.querySelector('#app') || document.querySelector('[v-cloak]');
@@ -26,9 +28,10 @@ document.addEventListener('DOMContentLoaded', () => {
26
28
 
27
29
  const { createApp } = Vue;
28
30
 
29
- const app = createApp({
31
+ const appOptions = {
30
32
  data() {
31
33
  return {
34
+ brandHovered: false,
32
35
  lang: 'zh',
33
36
  appVersion: '',
34
37
  mainTab: 'dashboard',
@@ -71,6 +74,9 @@ document.addEventListener('DOMContentLoaded', () => {
71
74
  showAgentsModal: false,
72
75
  showSkillsModal: false,
73
76
  showHealthCheckModal: false,
77
+ showCodexBridgePoolModal: false,
78
+ showClaudeBridgePoolModal: false,
79
+ showWebhookModal: false,
74
80
  // Plugins
75
81
  pluginsActiveId: 'prompt-templates',
76
82
  pluginsLoading: false,
@@ -98,6 +104,7 @@ document.addEventListener('DOMContentLoaded', () => {
98
104
  confirmDialogResolver: null,
99
105
  configTemplateContent: '',
100
106
  configTemplateApplying: false,
107
+ configTemplateContext: 'codex',
101
108
  configTemplateDiffVisible: false,
102
109
  configTemplateDiffLoading: false,
103
110
  configTemplateDiffError: '',
@@ -226,6 +233,7 @@ document.addEventListener('DOMContentLoaded', () => {
226
233
  sessionPreviewHeaderEl: null,
227
234
  sessionPreviewHeaderResizeObserver: null,
228
235
  sessionListRenderEnabled: false,
236
+ preserveSessionRenderOnTabLeave: true,
229
237
  sessionListVisibleCount: 0,
230
238
  sessionListInitialBatchSize: 40,
231
239
  sessionListLoadStep: 80,
@@ -416,6 +424,27 @@ document.addEventListener('DOMContentLoaded', () => {
416
424
  },
417
425
 
418
426
  mounted() {
427
+ // URL 规范化:将 /web-ui/* 重定向到根路径 /
428
+ try {
429
+ const pathname = window.location.pathname;
430
+ if (pathname === '/web-ui' || pathname === '/web-ui/' || pathname === '/web-ui/index.html') {
431
+ const url = new URL(window.location.href);
432
+ url.pathname = '/';
433
+ // 移除查询参数和 hash,保持 URL 纯净
434
+ url.search = '';
435
+ url.hash = '';
436
+ window.location.replace(url.toString());
437
+ return;
438
+ }
439
+ // 清理任何查询参数和 hash,保持 URL 为 /
440
+ if (window.location.search || window.location.hash) {
441
+ const url = new URL(window.location.href);
442
+ url.search = '';
443
+ url.hash = '';
444
+ window.history.replaceState(null, '', url.toString());
445
+ }
446
+ } catch (_) {}
447
+
419
448
  if (typeof this.initI18n === 'function') {
420
449
  this.initI18n();
421
450
  }
@@ -644,7 +673,13 @@ document.addEventListener('DOMContentLoaded', () => {
644
673
 
645
674
  computed: createAppComputed(),
646
675
  methods: createAppMethods()
647
- });
676
+ };
677
+
678
+ if (typeof window.__CODEXMATE_WEB_UI_RENDER__ === 'function') {
679
+ appOptions.render = window.__CODEXMATE_WEB_UI_RENDER__;
680
+ }
681
+
682
+ const app = createApp(appOptions);
648
683
 
649
684
  app.mount('#app');
650
685
  });