Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
564d27b
feat(web-ui): enable claude share command button
ymkiux May 17, 2026
a9e567a
feat(web-ui): move version to brand header and simplify layout
ymkiux May 17, 2026
7e13a4e
fix(web-ui): make language switcher always visible at sidebar bottom
ymkiux May 17, 2026
2701016
fix(web-ui): fix language switcher visibility at sidebar bottom
ymkiux May 17, 2026
ae446ef
feat(web-ui): add context window utilization warning for sessions
ymkiux May 17, 2026
12b3038
test: update test to match enabled share command button
ymkiux May 17, 2026
b839fc4
test: allow sessionContextUtilization in parity test
ymkiux May 17, 2026
3d896b2
fix(search): CJK token matching + E2E stability fixes
ymkiux May 17, 2026
4ca5164
feat(web-ui): Codex provider UI improvements + edit key eye toggle
ymkiux May 18, 2026
707462f
feat(web-ui): add eye toggle for Claude edit config API key
ymkiux May 18, 2026
cde92c3
test(web-ui): update parity and share command tests for provider UI c…
ymkiux May 18, 2026
c746613
feat(claude): add local bridge load balancing for Claude providers
ymkiux May 18, 2026
9c3e6c9
fix(claude): add local provider card to Claude config panel
ymkiux May 18, 2026
55322e5
feat(cli): add Windows system tray icon for running indicator
ymkiux May 18, 2026
db99a4f
fix(claude): remove provider health check section from config panel
ymkiux May 18, 2026
2bac697
fix(dashboard): remove providers health section from Doctor panel
ymkiux May 18, 2026
3ea1e0d
fix(claude): remove provider health check section from Claude config …
ymkiux May 18, 2026
b43af44
fix(claude): remove health check results display, keep button and hint
ymkiux May 18, 2026
82123ea
fix(codex): remove health check results display, keep button and hint
ymkiux May 18, 2026
dec373a
feat(cli): add update command
ymkiux May 18, 2026
58999a5
fix(tray): fix pid variable bug and improve robustness
ymkiux May 18, 2026
75ad600
fix(tray): fix powershell heredoc indentation syntax error
ymkiux May 18, 2026
e3854bf
fix(tray): improve UI compatibility and add fallback icon
ymkiux May 18, 2026
3f54c4b
refactor(tray): use pure powershell with STA mode and hidden form for…
ymkiux May 18, 2026
f9011e0
refactor(cli): remove experimental windows tray icon feature
ymkiux May 18, 2026
6a5b08f
docs(readme): modernize style and fix consistency tests
ymkiux May 18, 2026
655b04e
docs(readme): remove sub-projects and ecosystem section
ymkiux May 18, 2026
32489f5
docs(readme): fix heading spacing and remove sub-projects section
ymkiux May 18, 2026
5b4ee20
chore: bump version to 0.0.33
ymkiux May 18, 2026
dd28a6b
docs(readme): restore missing badges with flat-square style
ymkiux May 18, 2026
52d577f
docs(readme): add Live Agent Sync to progress table
ymkiux May 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
400 changes: 92 additions & 308 deletions README.md

Large diffs are not rendered by default.

412 changes: 94 additions & 318 deletions README.zh.md

Large diffs are not rendered by default.

418 changes: 306 additions & 112 deletions cli.js

Large diffs are not rendered by default.

227 changes: 227 additions & 0 deletions cli/local-bridge.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
const fs = require('fs');
const http = require('http');
const https = require('https');
const { URL } = require('url');
const {
readOpenaiBridgeSettings,
Expand All @@ -21,6 +23,8 @@ const { isValidHttpUrl, normalizeBaseUrl, joinApiUrl } = require('../lib/cli-uti

const BUILTIN_PROXY_PROVIDER_NAME = 'codexmate-proxy';
const BUILTIN_LOCAL_PROVIDER_NAME = 'local';
const CLAUDE_LOCAL_PROVIDER_NAME = 'claude-local';
const CLAUDE_LOCAL_EXCLUDED_KEY = 'claudeLocalExcluded';
const CIRCUIT_BREAKER_THRESHOLD = 3;
const CIRCUIT_BREAKER_COOLDOWN_MS = 5 * 60 * 1000;

Expand Down Expand Up @@ -51,6 +55,31 @@ function buildUpstreamPool(readConfigFn, openaiBridgeFile, excludedProviders) {
return { pool };
}

function buildClaudeUpstreamPool(claudeProvidersFile, excludedProviders) {
let raw;
try {
if (!fs.existsSync(claudeProvidersFile)) return { error: '暂无可用上游 provider,请先添加 Claude 提供商' };
raw = JSON.parse(fs.readFileSync(claudeProvidersFile, 'utf-8'));
} catch (e) { return { error: '读取 Claude 提供商配置失败' }; }
const providers = (raw && typeof raw.providers === 'object' && !Array.isArray(raw.providers))
? raw.providers : {};
const pool = [];
const excludedSet = new Set(
(Array.isArray(excludedProviders) ? excludedProviders : [])
.filter(n => typeof n === 'string' && n.trim())
.map(n => n.trim().toLowerCase())
);
for (const [name, p] of Object.entries(providers)) {
if (!p || typeof p !== 'object') continue;
if (excludedSet.has(name.toLowerCase())) continue;
const baseUrl = typeof p.baseUrl === 'string' ? p.baseUrl.trim() : '';
if (!baseUrl || !isValidHttpUrl(normalizeBaseUrl(baseUrl))) continue;
pool.push({ name, baseUrl: normalizeBaseUrl(baseUrl), apiKey: typeof p.apiKey === 'string' ? p.apiKey : '' });
}
if (pool.length === 0) return { error: '请先添加可用的 Claude 上游提供商' };
return { pool };
}

function resolveUpstreamAuth(entry, openaiBridgeFile, reqAuthToken) {
if (entry.authMethod === 'codexmate' || entry.requiresOpenaiAuth) {
const token = reqAuthToken || '';
Expand All @@ -69,6 +98,7 @@ function resolveUpstreamAuth(entry, openaiBridgeFile, reqAuthToken) {
function createLocalBridgeHttpHandler(options = {}) {
const readConfigFn = options.readConfigFn;
const openaiBridgeFile = options.openaiBridgeFile;
const claudeProvidersFile = options.claudeProvidersFile || '';
const expectedToken = typeof options.expectedToken === 'string' ? options.expectedToken.trim() : '';
const maxBodySize = Number.isFinite(options.maxBodySize) ? options.maxBodySize : 0;
const httpAgent = options.httpAgent;
Expand Down Expand Up @@ -128,10 +158,207 @@ function createLocalBridgeHttpHandler(options = {}) {
} catch (e) { return []; }
}

function streamClaudeUpstream(targetUrl, options) {
const parsed = new URL(targetUrl);
const transport = parsed.protocol === 'https:' ? https : http;
const bodyText = options.body || '';
const headers = {
'Accept': 'text/event-stream',
'Content-Type': 'application/json',
...(options.headers || {})
};
if (bodyText) {
headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
}
const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0 ? options.maxBytes : 0;
const res = options.res;

return new Promise((resolve) => {
let settled = false;
let upstreamReq = null;
const finish = (value) => { if (!settled) { settled = true; resolve(value); } };
const abortUpstream = () => { if (upstreamReq) try { upstreamReq.destroy(new Error('client aborted')); } catch (_) {} };
if (res && typeof res.once === 'function') res.once('close', abortUpstream);

upstreamReq = transport.request({
protocol: parsed.protocol,
hostname: parsed.hostname,
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
method: options.method || 'POST',
path: `${parsed.pathname}${parsed.search}`,
headers,
agent: parsed.protocol === 'https:' ? options.httpsAgent : options.httpAgent
}, (upstreamRes) => {
const status = upstreamRes.statusCode || 0;
if (status >= 400) {
const chunks = [];
let size = 0;
upstreamRes.on('data', (chunk) => {
if (!chunk) return;
if (maxBytes > 0) { size += chunk.length; if (size > maxBytes) { finish({ ok: false, status, error: 'response too large' }); return; } }
chunks.push(chunk);
});
upstreamRes.on('end', () => finish({ ok: false, status, error: chunks.length ? Buffer.concat(chunks).toString('utf-8') : 'Upstream error' }));
return;
}
// SSE: pipe directly to client
if (!res.headersSent) {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
});
if (typeof res.flushHeaders === 'function') res.flushHeaders();
}
upstreamRes.pipe(res);
upstreamRes.on('end', () => finish({ ok: true, status }));
upstreamRes.on('error', (err) => finish({ ok: false, status, error: err.message }));
});
upstreamReq.on('error', (err) => finish({ ok: false, error: err.message }));
upstreamReq.setTimeout(5 * 60 * 1000, () => { try { upstreamReq.destroy(new Error('timeout')); } catch (_) {} });
if (bodyText) upstreamReq.write(bodyText);
upstreamReq.end();
});
}

function readClaudeExcludedProviders() {
if (!claudeProvidersFile) return [];
try {
if (!fs.existsSync(claudeProvidersFile)) return [];
const raw = JSON.parse(fs.readFileSync(claudeProvidersFile, 'utf-8'));
return Array.isArray(raw.excludedProviders)
? raw.excludedProviders.filter(n => typeof n === 'string' && n.trim())
: [];
} catch (e) { return []; }
}

async function handleClaudeLocalBridge(req, res, parsedUrl) {
try {
const token = extractAuthorizationToken(req);
const remoteAddr = req && req.socket ? req.socket.remoteAddress : '';
const isLoopback = isLoopbackAddress(remoteAddr);
if (!isLoopback && !expectedToken) {
res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: 'Remote access is disabled (set CODEXMATE_HTTP_TOKEN)' }));
return;
}
if (!token && !isLoopback) {
res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
if (!isLoopback && token && token !== expectedToken) {
res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}

const poolResult = buildClaudeUpstreamPool(claudeProvidersFile, readClaudeExcludedProviders());
if (poolResult.error) {
res.writeHead(503, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: poolResult.error }));
return;
}
const pool = poolResult.pool;
const { entry } = pickUpstream(pool);

const suffix = (parsedUrl.pathname || '').replace(/^\/bridge\/claude-local\/?/, '');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restrict Claude-local proxy to the intended endpoint (/v1/messages) and method.

Current logic proxies arbitrary suffixes under /bridge/claude-local/*. That expands attack surface and bypasses the stated contract of messages-only proxying. Add explicit path/method validation before forwarding upstream.

Suggested guard
-            // Proxy Anthropic Messages API requests
+            // Only allow Anthropic Messages API
+            const method = (req.method || 'GET').toUpperCase();
+            if (suffix !== 'v1/messages' && suffix !== 'v1/messages/') {
+                res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
+                res.end(JSON.stringify({ error: 'Method Not Allowed' }));
+                return;
+            }
+            if (method !== 'POST') {
+                res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
+                res.end(JSON.stringify({ error: 'Method Not Allowed' }));
+                return;
+            }

Also applies to: 278-330

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/local-bridge.js` at line 266, The proxy currently allows any suffix under
/bridge/claude-local/*; add an explicit guard to only forward POST requests
whose upstream path is exactly /v1/messages. After computing suffix from
parsedUrl.pathname, normalize it to a single-leading-slash path and check that
request.method === 'POST' and normalizedSuffix === '/v1/messages'; if either
check fails return a 404 or 405 (and do not forward). Apply the same validation
to the other forwarding branch(s) handling the Claude-local proxy (the blocks
around the earlier suffix use, i.e., the code covering lines ~278-330) so all
proxy entry points enforce method+path equality before proxying upstream.

if (!suffix) {
if ((req.method || 'GET').toUpperCase() !== 'GET') {
res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ object: 'codexmate.claude_local_bridge', provider: entry.name, status: 'ok', pool: pool.map(p => p.name) }));
return;
}

// Proxy Anthropic Messages API requests
const bodyResult = await readRequestBody(req, maxBodySize);
if (bodyResult.error) {
res.writeHead(413, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: bodyResult.error }));
return;
}

let parsedBody;
try { parsedBody = bodyResult.body ? JSON.parse(bodyResult.body) : {}; } catch (_) { parsedBody = {}; }
const wantsStream = !!(parsedBody && parsedBody.stream);
const upstreamUrl = joinApiUrl(entry.baseUrl.replace(/\/+$/, ''), suffix);
const headers = { 'Content-Type': 'application/json' };
if (entry.apiKey) {
headers['x-api-key'] = entry.apiKey.startsWith('Bearer ') ? entry.apiKey.slice(7) : entry.apiKey;
}
if (token && !entry.apiKey) {
headers['x-api-key'] = token.startsWith('Bearer ') ? token.slice(7) : token;
}
headers['anthropic-version'] = '2023-06-01';

if (wantsStream) {
// Streaming proxy: pipe upstream SSE directly to client
const upstreamResult = await streamClaudeUpstream(upstreamUrl, {
method: req.method || 'POST',
body: bodyResult.body,
headers,
maxBytes: maxUpstreamBytes,
httpAgent,
httpsAgent,
res
});
if (!upstreamResult.ok) {
recordFailure(entry.name);
if (!res.headersSent) {
res.writeHead(upstreamResult.status || 502, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: upstreamResult.error || 'Upstream error' }));
}
return;
}
recordSuccess(entry.name);
return;
}

// Non-streaming proxy
const upstreamResult = await retryTransientRequest(() => proxyRequestJson(upstreamUrl, {
method: req.method || 'POST',
body: bodyResult.body || null,
headers,
maxBytes: maxUpstreamBytes,
httpAgent,
httpsAgent
}));

if (!upstreamResult.ok) {
recordFailure(entry.name);
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResult.error}` }));
return;
}

recordSuccess(entry.name);
const statusCode = Number.isFinite(upstreamResult.status) ? upstreamResult.status : 200;
res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(upstreamResult.bodyText || '{}');
} catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: e && e.message ? e.message : 'Internal Error' }));
}
}

const handler = (req, res) => {
let parsedUrl;
try { parsedUrl = new URL(req.url || '/', 'http://localhost'); } catch (_) { return false; }
const pathname = parsedUrl.pathname || '/';

// Claude local bridge: /bridge/claude-local/v1/messages
if (pathname.startsWith('/bridge/claude-local/')) {
if (!claudeProvidersFile) return false;
void handleClaudeLocalBridge(req, res, parsedUrl);
return true;
}

// Codex local bridge: /bridge/local/v1
if (!pathname.startsWith('/bridge/local/')) return false;
const suffix = pathname.replace(/^\/bridge\/local\/?/, '');
if (!suffix.startsWith('v1')) return false;
Expand Down
Loading
Loading