codexmate 0.0.33 → 0.0.34
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/cli/agents-files.js +6 -0
- package/cli/archive-helpers.js +11 -4
- package/cli/local-bridge.js +9 -4
- package/cli/openai-bridge.js +1 -1
- package/cli/update.js +11 -2
- package/cli.js +65 -9
- package/lib/cli-webhook.js +29 -1
- package/package.json +1 -1
- package/web-ui/app.js +4 -0
- package/web-ui/index.html +1 -0
- package/web-ui/logic.claude.mjs +4 -0
- package/web-ui/modules/app.methods.claude-config.mjs +37 -0
- package/web-ui/modules/app.methods.codex-config.mjs +11 -3
- package/web-ui/modules/app.methods.navigation.mjs +14 -0
- package/web-ui/modules/app.methods.session-browser.mjs +5 -6
- package/web-ui/modules/app.methods.startup-claude.mjs +9 -0
- package/web-ui/modules/app.methods.webhook.mjs +8 -0
- package/web-ui/modules/i18n.dict.mjs +3 -0
- package/web-ui/partials/index/modal-webhook.html +42 -0
- package/web-ui/partials/index/modals-basic.html +50 -0
- package/web-ui/partials/index/panel-config-claude.html +14 -25
- package/web-ui/partials/index/panel-config-codex.html +7 -21
- package/web-ui/partials/index/panel-settings.html +117 -149
- package/web-ui/styles/bridge-pool.css +69 -0
- package/web-ui/styles/settings-panel.css +300 -234
- package/web-ui/styles/webhook.css +38 -4
package/cli/agents-files.js
CHANGED
|
@@ -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);
|
package/cli/archive-helpers.js
CHANGED
|
@@ -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
|
-
|
|
345
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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
|
}
|
package/cli/local-bridge.js
CHANGED
|
@@ -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:
|
|
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:
|
|
330
|
+
body: bodyToForward || null,
|
|
326
331
|
headers,
|
|
327
332
|
maxBytes: maxUpstreamBytes,
|
|
328
333
|
httpAgent,
|
package/cli/openai-bridge.js
CHANGED
|
@@ -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 = '
|
|
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
|
|
151
|
-
|
|
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 = '
|
|
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
|
-
|
|
4745
|
-
|
|
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) {
|
|
@@ -7892,7 +7908,7 @@ function normalizeImportPayload(payload) {
|
|
|
7892
7908
|
const name = item.name || item.provider || '';
|
|
7893
7909
|
const baseUrl = item.baseUrl || item.base_url || item.url || '';
|
|
7894
7910
|
const apiKey = item.apiKey ?? item.key ?? item.preferred_auth_method ?? null;
|
|
7895
|
-
if (name && baseUrl) {
|
|
7911
|
+
if (name && baseUrl && /^[a-zA-Z0-9_\-.\s]+$/.test(name)) {
|
|
7896
7912
|
providers[name] = { baseUrl, apiKey };
|
|
7897
7913
|
}
|
|
7898
7914
|
}
|
|
@@ -7901,7 +7917,7 @@ function normalizeImportPayload(payload) {
|
|
|
7901
7917
|
if (!item || typeof item !== 'object') continue;
|
|
7902
7918
|
const baseUrl = item.baseUrl || item.base_url || item.url || '';
|
|
7903
7919
|
const apiKey = item.apiKey ?? item.key ?? item.preferred_auth_method ?? null;
|
|
7904
|
-
if (name && baseUrl) {
|
|
7920
|
+
if (name && baseUrl && /^[a-zA-Z0-9_\-.\s]+$/.test(name)) {
|
|
7905
7921
|
providers[name] = { baseUrl, apiKey };
|
|
7906
7922
|
}
|
|
7907
7923
|
}
|
|
@@ -9237,6 +9253,9 @@ function applyClaudeSettingsRaw(params = {}) {
|
|
|
9237
9253
|
if (!content.trim()) {
|
|
9238
9254
|
return { error: '内容不能为空' };
|
|
9239
9255
|
}
|
|
9256
|
+
if (content.length > 1024 * 1024) {
|
|
9257
|
+
return { error: '内容过大(最大 1MB)' };
|
|
9258
|
+
}
|
|
9240
9259
|
let parsed;
|
|
9241
9260
|
try {
|
|
9242
9261
|
parsed = JSON.parse(content);
|
|
@@ -10119,7 +10138,7 @@ function assertRequestAuthorized(req, res) {
|
|
|
10119
10138
|
return { ok: false, mode: 'missing-token' };
|
|
10120
10139
|
}
|
|
10121
10140
|
const actual = extractRequestToken(req);
|
|
10122
|
-
if (!actual || actual
|
|
10141
|
+
if (!actual || !safeTimingEqual(actual, expected)) {
|
|
10123
10142
|
writeJsonResponse(res, 401, { error: 'Unauthorized' });
|
|
10124
10143
|
return { ok: false, mode: 'unauthorized' };
|
|
10125
10144
|
}
|
|
@@ -10576,7 +10595,38 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
|
|
|
10576
10595
|
res.end('Internal Server Error');
|
|
10577
10596
|
};
|
|
10578
10597
|
|
|
10598
|
+
const rateLimitMap = new Map();
|
|
10599
|
+
const RATE_LIMIT_WINDOW_MS = 60000;
|
|
10600
|
+
const RATE_LIMIT_MAX = 120;
|
|
10601
|
+
function checkRateLimit(key) {
|
|
10602
|
+
const now = Date.now();
|
|
10603
|
+
const entry = rateLimitMap.get(key);
|
|
10604
|
+
if (!entry || now - entry.start > RATE_LIMIT_WINDOW_MS) {
|
|
10605
|
+
rateLimitMap.set(key, { start: now, count: 1 });
|
|
10606
|
+
return true;
|
|
10607
|
+
}
|
|
10608
|
+
entry.count++;
|
|
10609
|
+
if (entry.count > RATE_LIMIT_MAX) return false;
|
|
10610
|
+
return true;
|
|
10611
|
+
}
|
|
10612
|
+
setInterval(function () {
|
|
10613
|
+
const now = Date.now();
|
|
10614
|
+
for (const [key, entry] of rateLimitMap.entries()) {
|
|
10615
|
+
if (now - entry.start > RATE_LIMIT_WINDOW_MS * 2) rateLimitMap.delete(key);
|
|
10616
|
+
}
|
|
10617
|
+
}, RATE_LIMIT_WINDOW_MS).unref();
|
|
10618
|
+
|
|
10579
10619
|
const server = http.createServer((req, res) => {
|
|
10620
|
+
const securityHeaders = {
|
|
10621
|
+
'X-Content-Type-Options': 'nosniff',
|
|
10622
|
+
'X-Frame-Options': 'DENY',
|
|
10623
|
+
'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:"
|
|
10624
|
+
};
|
|
10625
|
+
const origWriteHead = res.writeHead.bind(res);
|
|
10626
|
+
res.writeHead = function (statusCode, headers) {
|
|
10627
|
+
const merged = Object.assign({}, securityHeaders, headers || {});
|
|
10628
|
+
return origWriteHead(statusCode, merged);
|
|
10629
|
+
};
|
|
10580
10630
|
const requestPath = (req.url || '/').split('?')[0];
|
|
10581
10631
|
const sendJson = (statusCode, payload) => {
|
|
10582
10632
|
const body = JSON.stringify(payload || {}, null, 2);
|
|
@@ -10604,6 +10654,12 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
|
|
|
10604
10654
|
|| remoteAddr === '::1'
|
|
10605
10655
|
|| remoteAddr === '::ffff:127.0.0.1';
|
|
10606
10656
|
if (!isLoopback) {
|
|
10657
|
+
const rateLimitKey = (remoteAddr || 'unknown') + ':' + requestPath;
|
|
10658
|
+
if (!checkRateLimit(rateLimitKey)) {
|
|
10659
|
+
res.writeHead(429, { 'Content-Type': 'application/json; charset=utf-8', 'Retry-After': '60' });
|
|
10660
|
+
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
|
10661
|
+
return;
|
|
10662
|
+
}
|
|
10607
10663
|
const expected = typeof process.env.CODEXMATE_HTTP_TOKEN === 'string'
|
|
10608
10664
|
? process.env.CODEXMATE_HTTP_TOKEN.trim()
|
|
10609
10665
|
: '';
|
|
@@ -10619,7 +10675,7 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
|
|
|
10619
10675
|
const actual = match && match[1]
|
|
10620
10676
|
? match[1].trim()
|
|
10621
10677
|
: (rawAuth ? rawAuth : (typeof headers['x-codexmate-token'] === 'string' ? String(headers['x-codexmate-token']).trim() : ''));
|
|
10622
|
-
if (!actual || actual
|
|
10678
|
+
if (!actual || !safeTimingEqual(actual, expected)) {
|
|
10623
10679
|
sendJson(401, { error: 'Unauthorized' });
|
|
10624
10680
|
return;
|
|
10625
10681
|
}
|
package/lib/cli-webhook.js
CHANGED
|
@@ -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
package/web-ui/app.js
CHANGED
|
@@ -71,6 +71,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
71
71
|
showAgentsModal: false,
|
|
72
72
|
showSkillsModal: false,
|
|
73
73
|
showHealthCheckModal: false,
|
|
74
|
+
showCodexBridgePoolModal: false,
|
|
75
|
+
showClaudeBridgePoolModal: false,
|
|
76
|
+
showWebhookModal: false,
|
|
74
77
|
// Plugins
|
|
75
78
|
pluginsActiveId: 'prompt-templates',
|
|
76
79
|
pluginsLoading: false,
|
|
@@ -98,6 +101,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
98
101
|
confirmDialogResolver: null,
|
|
99
102
|
configTemplateContent: '',
|
|
100
103
|
configTemplateApplying: false,
|
|
104
|
+
configTemplateContext: 'codex',
|
|
101
105
|
configTemplateDiffVisible: false,
|
|
102
106
|
configTemplateDiffLoading: false,
|
|
103
107
|
configTemplateDiffError: '',
|
package/web-ui/index.html
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
<!-- @include ./partials/index/panel-plugins.html -->
|
|
27
27
|
<!-- @include ./partials/index/layout-footer.html -->
|
|
28
28
|
<!-- @include ./partials/index/modals-basic.html -->
|
|
29
|
+
<!-- @include ./partials/index/modal-webhook.html -->
|
|
29
30
|
<!-- @include ./partials/index/modal-openclaw-config.html -->
|
|
30
31
|
<!-- @include ./partials/index/modal-config-template-agents.html -->
|
|
31
32
|
<!-- @include ./partials/index/modal-skills.html -->
|
package/web-ui/logic.claude.mjs
CHANGED
|
@@ -111,6 +111,10 @@ export function matchClaudeConfigFromSettings(claudeConfigs = {}, env = {}) {
|
|
|
111
111
|
if (!normalizedSettings.baseUrl || !normalizedSettings.model || !hasClaudeCredential(normalizedSettings)) {
|
|
112
112
|
return '';
|
|
113
113
|
}
|
|
114
|
+
// 检测本地桥接 URL
|
|
115
|
+
if (typeof normalizedSettings.baseUrl === 'string' && normalizedSettings.baseUrl.includes('/bridge/claude-local/')) {
|
|
116
|
+
return 'claude-local';
|
|
117
|
+
}
|
|
114
118
|
const comparableSettingsUrl = normalizeClaudeComparableUrl(normalizedSettings.baseUrl);
|
|
115
119
|
const entries = Object.entries(claudeConfigs || {});
|
|
116
120
|
for (const [name, config] of entries) {
|
|
@@ -264,6 +264,43 @@ export function createClaudeConfigMethods(options = {}) {
|
|
|
264
264
|
|
|
265
265
|
claudeLocalBridgeConfigured() {
|
|
266
266
|
return this.claudeLocalBridgeCandidateProviders().some(p => p.hasKey);
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
async applyClaudeLocalBridge() {
|
|
270
|
+
this.currentClaudeConfig = 'claude-local';
|
|
271
|
+
try { localStorage.setItem('currentClaudeConfig', 'claude-local'); } catch (_) {}
|
|
272
|
+
this.refreshClaudeModelContext();
|
|
273
|
+
|
|
274
|
+
const candidates = this.claudeLocalBridgeCandidateProviders();
|
|
275
|
+
if (candidates.length === 0) {
|
|
276
|
+
return this.showMessage('请先添加并配置至少一个 Claude 提供商', 'error');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
const res = await api('claude-local-bridge-toggle', { enable: true });
|
|
281
|
+
if (res.error) {
|
|
282
|
+
this.showMessage(res.error || '启用本地负载均衡失败', 'error');
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
this.showMessage('Claude 本地负载均衡已启用', 'success');
|
|
286
|
+
} catch (e) {
|
|
287
|
+
this.showMessage('启用本地负载均衡失败', 'error');
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
async openClaudeConfigTemplateEditor() {
|
|
292
|
+
try {
|
|
293
|
+
const res = await api('get-claude-settings-raw');
|
|
294
|
+
if (res.error) {
|
|
295
|
+
this.showMessage(res.error, 'error');
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
this.configTemplateContent = res.content || '{}';
|
|
299
|
+
this.configTemplateContext = 'claude';
|
|
300
|
+
this.showConfigTemplateModal = true;
|
|
301
|
+
} catch (e) {
|
|
302
|
+
this.showMessage('加载 Claude settings 失败', 'error');
|
|
303
|
+
}
|
|
267
304
|
}
|
|
268
305
|
};
|
|
269
306
|
}
|
|
@@ -558,6 +558,7 @@ export function createCodexConfigMethods(options = {}) {
|
|
|
558
558
|
template = `${template.trimEnd()}\n\n${appendBlock}\n`;
|
|
559
559
|
}
|
|
560
560
|
this.configTemplateContent = template;
|
|
561
|
+
this.configTemplateContext = 'codex';
|
|
561
562
|
this.showConfigTemplateModal = true;
|
|
562
563
|
} catch (e) {
|
|
563
564
|
this.showMessage('加载模板失败', 'error');
|
|
@@ -807,9 +808,16 @@ export function createCodexConfigMethods(options = {}) {
|
|
|
807
808
|
const performApply = async () => {
|
|
808
809
|
this.configTemplateApplying = true;
|
|
809
810
|
try {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
811
|
+
let res;
|
|
812
|
+
if (this.configTemplateContext === 'claude') {
|
|
813
|
+
res = await api('apply-claude-settings-raw', {
|
|
814
|
+
content: this.configTemplateContent
|
|
815
|
+
});
|
|
816
|
+
} else {
|
|
817
|
+
res = await api('apply-config-template', {
|
|
818
|
+
template: this.configTemplateContent
|
|
819
|
+
});
|
|
820
|
+
}
|
|
813
821
|
if (res.error) {
|
|
814
822
|
this.showMessage(res.error, 'error');
|
|
815
823
|
return;
|
|
@@ -419,6 +419,20 @@
|
|
|
419
419
|
mainTab: targetTab,
|
|
420
420
|
configMode: targetTab === 'config' ? this.configMode : this.configMode
|
|
421
421
|
});
|
|
422
|
+
if (targetTab !== 'sessions') {
|
|
423
|
+
try {
|
|
424
|
+
const url = new URL(window.location.href);
|
|
425
|
+
if (url.pathname !== '/session') {
|
|
426
|
+
url.searchParams.delete('s_source');
|
|
427
|
+
url.searchParams.delete('s_path');
|
|
428
|
+
url.searchParams.delete('s_query');
|
|
429
|
+
url.searchParams.delete('s_role');
|
|
430
|
+
url.searchParams.delete('s_time');
|
|
431
|
+
url.searchParams.delete('tab');
|
|
432
|
+
window.history.replaceState(null, '', url.toString());
|
|
433
|
+
}
|
|
434
|
+
} catch (_) {}
|
|
435
|
+
}
|
|
422
436
|
this.cancelTouchNavIntentReset();
|
|
423
437
|
if (targetTab === 'sessions') {
|
|
424
438
|
this.cancelScheduledSessionTabDeferredTeardown();
|
|
@@ -479,14 +479,13 @@ export function createSessionBrowserMethods(options = {}) {
|
|
|
479
479
|
if (typeof text !== 'string' || !text) return text;
|
|
480
480
|
var tokens = this.queryTokens;
|
|
481
481
|
if (!tokens || tokens.length === 0) return text;
|
|
482
|
-
var
|
|
482
|
+
var escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
483
483
|
for (var i = 0; i < tokens.length; i++) {
|
|
484
|
-
var token = tokens[i];
|
|
485
|
-
var
|
|
486
|
-
|
|
487
|
-
result = result.replace(re, '<mark>$1</mark>');
|
|
484
|
+
var token = tokens[i].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
485
|
+
var re = new RegExp('(' + token + ')', 'gi');
|
|
486
|
+
escaped = escaped.replace(re, '<mark>$1</mark>');
|
|
488
487
|
}
|
|
489
|
-
return
|
|
488
|
+
return escaped;
|
|
490
489
|
},
|
|
491
490
|
|
|
492
491
|
async onSessionSourceChange(event) {
|
|
@@ -383,6 +383,15 @@ export function createStartupClaudeMethods(options = {}) {
|
|
|
383
383
|
},
|
|
384
384
|
|
|
385
385
|
syncClaudeModelFromConfig() {
|
|
386
|
+
if (this.currentClaudeConfig === 'claude-local') {
|
|
387
|
+
const candidates = this.claudeLocalBridgeCandidateProviders
|
|
388
|
+
? this.claudeLocalBridgeCandidateProviders()
|
|
389
|
+
: [];
|
|
390
|
+
const active = candidates.find(cp => !this.isClaudeLocalBridgeExcluded(cp.name));
|
|
391
|
+
this.currentClaudeModel = active && active.model ? active.model : '';
|
|
392
|
+
this.claudeCustomModelDraft = this.currentClaudeModel;
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
386
395
|
const config = this.getCurrentClaudeConfig();
|
|
387
396
|
this.currentClaudeModel = config && config.model ? config.model : '';
|
|
388
397
|
this.claudeCustomModelDraft = this.currentClaudeModel;
|
|
@@ -2,6 +2,14 @@ import { api } from './api.mjs';
|
|
|
2
2
|
|
|
3
3
|
export function createWebhookMethods() {
|
|
4
4
|
return {
|
|
5
|
+
openWebhookModal() {
|
|
6
|
+
this.showWebhookModal = true;
|
|
7
|
+
},
|
|
8
|
+
|
|
9
|
+
closeWebhookModal() {
|
|
10
|
+
this.showWebhookModal = false;
|
|
11
|
+
},
|
|
12
|
+
|
|
5
13
|
async loadWebhookSettings() {
|
|
6
14
|
try {
|
|
7
15
|
const data = await api('get-webhook-config');
|
|
@@ -910,6 +910,7 @@ const DICT = Object.freeze({
|
|
|
910
910
|
'settings.tab.general': '通用',
|
|
911
911
|
'settings.tab.data': '数据',
|
|
912
912
|
'settings.tabs.aria': '设置分类',
|
|
913
|
+
'settings.quickSettings.title': '快捷设置',
|
|
913
914
|
'settings.sharePrefix.title': '分享命令前缀',
|
|
914
915
|
'settings.sharePrefix.meta': '影响 Web UI 里“复制分享命令”的前缀',
|
|
915
916
|
'settings.sharePrefix.label': '前缀',
|
|
@@ -1965,6 +1966,7 @@ const DICT = Object.freeze({
|
|
|
1965
1966
|
'settings.tab.general': '一般',
|
|
1966
1967
|
'settings.tab.data': 'データ',
|
|
1967
1968
|
'settings.tabs.aria': '設定カテゴリ',
|
|
1969
|
+
'settings.quickSettings.title': 'クイック設定',
|
|
1968
1970
|
'settings.sharePrefix.title': '共有コマンドプレフィックス',
|
|
1969
1971
|
'settings.sharePrefix.meta': 'Web UI の「共有コマンドをコピー」のプレフィックスに影響',
|
|
1970
1972
|
'settings.sharePrefix.label': 'プレフィックス',
|
|
@@ -3034,6 +3036,7 @@ const DICT = Object.freeze({
|
|
|
3034
3036
|
'settings.tab.general': 'General',
|
|
3035
3037
|
'settings.tab.data': 'Data',
|
|
3036
3038
|
'settings.tabs.aria': 'Settings categories',
|
|
3039
|
+
'settings.quickSettings.title': 'Quick Settings',
|
|
3037
3040
|
'settings.sharePrefix.title': 'Share command prefix',
|
|
3038
3041
|
'settings.sharePrefix.meta': 'Used as the prefix for “Copy share command” in the Web UI',
|
|
3039
3042
|
'settings.sharePrefix.label': 'Prefix',
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<!-- Webhook 配置模态框 -->
|
|
2
|
+
<div v-if="showWebhookModal" class="modal-overlay" @click.self="closeWebhookModal">
|
|
3
|
+
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="webhook-modal-title">
|
|
4
|
+
<div class="modal-title" id="webhook-modal-title">Webhook 配置</div>
|
|
5
|
+
|
|
6
|
+
<div class="form-group">
|
|
7
|
+
<label class="form-label">启用状态</label>
|
|
8
|
+
<label class="settings-toggle">
|
|
9
|
+
<input type="checkbox" v-model="webhookConfig.enabled">
|
|
10
|
+
<span>启用 Webhook</span>
|
|
11
|
+
</label>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<div class="form-group">
|
|
15
|
+
<label class="form-label">URL</label>
|
|
16
|
+
<input
|
|
17
|
+
v-model="webhookConfig.url"
|
|
18
|
+
class="form-input"
|
|
19
|
+
type="url"
|
|
20
|
+
placeholder="https://example.com/webhook"
|
|
21
|
+
autocomplete="off"
|
|
22
|
+
spellcheck="false">
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div class="form-group">
|
|
26
|
+
<label class="form-label">事件</label>
|
|
27
|
+
<div class="webhook-events-checkbox-list">
|
|
28
|
+
<label v-for="ev in webhookEventOptions" :key="ev" class="webhook-event-checkbox-item">
|
|
29
|
+
<input type="checkbox" :checked="webhookConfig.events.includes(ev)" @change="toggleWebhookEvent(ev)">
|
|
30
|
+
<span>{{ ev }}</span>
|
|
31
|
+
</label>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div class="btn-group">
|
|
36
|
+
<button class="btn btn-cancel" @click="closeWebhookModal">取消</button>
|
|
37
|
+
<button class="btn btn-confirm" @click="saveWebhookSettings" :disabled="webhookSaving">
|
|
38
|
+
{{ webhookSaving ? '保存中...' : '保存' }}
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|