diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 00000000..b291c405 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,65 @@ +name: Verify + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + node-quality: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run check:syntax + - run: npm run lint + - run: npm run frontend:check + - run: npm run test:frontend + - run: npm run frontend:build + + unit-tests: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + shard: [0, 1, 2, 3] + env: + TEST_SHARD_COUNT: 4 + TEST_SHARD_INDEX: ${{ matrix.shard }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run test:unit + + integration-tests: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run test:thinking + - run: npm run test:env + - run: npm run test:integration + + rust-tests: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + - run: cargo test --manifest-path src-tauri/Cargo.toml diff --git a/.gitignore b/.gitignore index 7c46e074..e01d1b02 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ # Local config and secrets proxy.config.json +proxy.config.before-*.json .env .env.* !.env.example @@ -30,6 +31,7 @@ build/ out/ *.zip tauri-ui/dist/ +/src-tauri/target*/ # Frontend mirrors (canonical source: tauri-ui/) /app.js diff --git a/.prettierignore b/.prettierignore index 46bf4f11..83076729 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,7 +4,7 @@ build/ coverage/ tauri-ui/dist/ tauri-ui/node_modules/ -src-tauri/target/ +src-tauri/target*/ src-tauri/gen/ **/*.min.js **/*.lock diff --git a/docs/release-governance.md b/docs/release-governance.md new file mode 100644 index 00000000..96402ce0 --- /dev/null +++ b/docs/release-governance.md @@ -0,0 +1,32 @@ +# Release package governance + +Release builds use a clean, generated runtime under `dist/release-runtime`. The staging step installs only production dependencies from `package-lock.json`, bundles the current Node executable, and writes a runtime manifest. + +## Commands + +- `npm run clean:release` removes `dist/` and every Cargo `src-tauri/target*` directory after verifying that each path is inside the repository. +- `npm run clean:release:cache` removes only Cargo build caches and preserves collected installers and portable archives. +- `npm run release:prepare` recreates the Node runtime and production dependency staging directory. +- `npm run release:check` verifies the static Tauri resource and script configuration. +- `npm run tauri:build` normalizes `CARGO_TARGET_DIR` to an absolute path, builds the Tauri installers, then copies final installers and a SHA-256 manifest to `dist/release/installers`. +- `npm run release:portable` builds a clean portable directory, validates it, and creates the zip archive. + +## Security rules + +Published packages must never contain: + +- `proxy.config.json`, `.env*`, management tokens, logs, JSONL audit files, or PID/lock files; +- `.hpm-runtime`, `.certs`, `.claude`, private keys, or symbolic links; +- npm lifecycle scripts or development dependencies. + +The portable build is intentionally stateless. It ships only `proxy.config.example.json`; the application creates local state on first run. Existing portable state is never copied into a new release. + +## Windows NSIS process-cleanup rules + +The current-user NSIS installer runs `src-tauri/nsis/stop-installed-processes.ps1` before install and uninstall. Keep these constraints in place: + +- Do not pass `$INSTDIR` or another path containing spaces through an `nsExec` command-line argument. NSIS and PowerShell quoting can split or shift the arguments. The cleanup script derives the fixed current-user location from `LOCALAPPDATA`. +- Never normalize a filesystem root from `C:\` to `C:`. On Windows, `C:` is drive-relative and resolves against that drive's current directory. Because NSIS sets its working directory to the install directory, this can make a normal installation path compare equal to the filesystem root. +- Preserve the filesystem-root safety check so cleanup can never scan, stop processes from, or perform lock checks against a whole drive. +- `test/installer-process-cleanup.test.js` must run the cleanup script with its working directory set to the installation directory. It must verify both that a real root is rejected and that the normal installation directory is accepted. +- Before publishing an installer change, run `npm run verify`, build the NSIS package, and perform one end-to-end current-user install. A successful source-level test alone is not sufficient for command-line and installer-host behavior. diff --git a/lib/proxy/audit.js b/lib/proxy/audit.js index 158056b1..6bfa95a9 100644 --- a/lib/proxy/audit.js +++ b/lib/proxy/audit.js @@ -19,6 +19,8 @@ const MAX_TEXT_CHARS = parseBoundedInteger( 1024, 1024 * 1024 ); +const SENSITIVE_AUDIT_KEY = + /^(?:authorization|proxy-authorization|(?:x-)?api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|bearer[-_]?token|cookie|password|passwd|secret|credential)$/i; const writeQueue = []; let writing = false; @@ -35,7 +37,7 @@ function compactAuditValue(value, depth = 0) { const result = {}; for (const [key, item] of Object.entries(value)) { - result[key] = compactAuditValue(item, depth + 1); + result[key] = SENSITIVE_AUDIT_KEY.test(key) ? '[redacted]' : compactAuditValue(item, depth + 1); } return result; } diff --git a/lib/proxy/builtin-model-routes.js b/lib/proxy/builtin-model-routes.js new file mode 100644 index 00000000..464762cb --- /dev/null +++ b/lib/proxy/builtin-model-routes.js @@ -0,0 +1,51 @@ +'use strict'; + +const BUILTIN_MODEL_ROUTES = [ + { from: 'gpt-5.6', profileId: 'openai-official', to: 'gpt-5.6-sol' }, + { from: 'gpt-5.6-sol', profileId: 'openai-official', to: 'gpt-5.6-sol' }, + { from: 'gpt-5.6-terra', profileId: 'openai-official', to: 'gpt-5.6-terra' }, + { from: 'gpt-5.6-luna', profileId: 'openai-official', to: 'gpt-5.6-luna' }, + { from: 'codex/gpt-5.6-sol', profileId: 'openai-official', to: 'gpt-5.6-sol' }, + { from: 'codex/gpt-5.6-terra', profileId: 'openai-official', to: 'gpt-5.6-terra' }, + { from: 'codex/gpt-5.6-luna', profileId: 'openai-official', to: 'gpt-5.6-luna' }, + { from: 'gemini-3.6-flash', profileId: 'google-official', to: 'gemini-3.6-flash' }, + { from: 'gemini-3.6-flash-medium', profileId: 'google-official', to: 'gemini-3.6-flash' }, + { from: 'gemini-3.6-flash-high', profileId: 'google-official', to: 'gemini-3.6-flash' }, + { from: 'gemini-3.6-flash-low', profileId: 'google-official', to: 'gemini-3.6-flash' }, + { from: 'Gemini 3.6 Flash (Medium)', profileId: 'google-official', to: 'gemini-3.6-flash' }, + { from: 'Gemini 3.6 Flash (High)', profileId: 'google-official', to: 'gemini-3.6-flash' }, + { from: 'Gemini 3.6 Flash (Low)', profileId: 'google-official', to: 'gemini-3.6-flash' }, +]; + +function mergeBuiltinModelRoutes(config) { + if (!config || typeof config !== 'object') return config; + const profiles = new Set( + (Array.isArray(config.profiles) ? config.profiles : []) + .map((profile) => String((profile && profile.id) || '').trim()) + .filter(Boolean) + ); + const routes = Array.isArray(config.modelRoutes) ? config.modelRoutes.slice() : []; + const existing = new Set( + routes + .map((route) => + String((route && route.from) || '') + .trim() + .toLowerCase() + ) + .filter(Boolean) + ); + + for (const route of BUILTIN_MODEL_ROUTES) { + const key = route.from.toLowerCase(); + if (!profiles.has(route.profileId) || existing.has(key)) continue; + routes.push({ ...route }); + existing.add(key); + } + + return { ...config, modelRoutes: routes }; +} + +module.exports = { + BUILTIN_MODEL_ROUTES, + mergeBuiltinModelRoutes, +}; diff --git a/lib/proxy/claude-self-test-target.js b/lib/proxy/claude-self-test-target.js new file mode 100644 index 00000000..f030b18f --- /dev/null +++ b/lib/proxy/claude-self-test-target.js @@ -0,0 +1,73 @@ +function appendClaudeMessagesPath(baseUrl) { + const normalized = String(baseUrl || '').replace(/\/+$/, ''); + if (!normalized) return ''; + return /\/v1$/i.test(normalized) ? normalized + '/messages' : normalized + '/v1/messages'; +} + +function createClaudeSelfTestTargetResolver(dependencies = {}) { + const { + claudeDesktopBaseUrl, + claudeDesktopProxyPrefix, + getClaudeDesktopGatewayConfig, + getClaudeDesktopRoutes, + getClaudeDesktopTraeProcessStyle, + getConfig, + getProfiles, + isTraeOfficialProfile, + isTraeOfficialTarget, + } = dependencies; + if (typeof getConfig !== 'function' || typeof getProfiles !== 'function') { + throw new TypeError('createClaudeSelfTestTargetResolver requires runtime getters'); + } + + return function getClaudeDesktopSelfTestTarget() { + const config = getConfig(); + const profiles = getProfiles(); + const routes = getClaudeDesktopRoutes(config); + if (!Array.isArray(routes) || routes.length === 0) return null; + const route = + routes.find((item) => { + const profile = profiles.find((candidate) => candidate?.id === item.profileId) || {}; + return isTraeOfficialProfile(profile) || isTraeOfficialTarget(profile.targetUrl); + }) || routes[0]; + if (!route?.from) return null; + + const gateway = getClaudeDesktopGatewayConfig(); + let baseUrl = ''; + let token = ''; + if (gateway?.isLocalProxy && gateway.baseUrl) { + baseUrl = gateway.baseUrl; + token = gateway.token || ''; + } else { + baseUrl = claudeDesktopBaseUrl(config) + claudeDesktopProxyPrefix; + token = typeof config?.claudeDesktop?.token === 'string' ? config.claudeDesktop.token : ''; + } + + const endpoint = appendClaudeMessagesPath(baseUrl); + if (!endpoint) return null; + const headers = { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + }; + if (token) headers.Authorization = 'Bearer ' + token; + const profile = profiles.find((item) => item?.id === route.profileId) || {}; + return { + source: gateway?.isLocalProxy + ? 'claude-desktop-gateway' + : 'configured-claude-desktop-gateway', + endpoint, + headers, + model: route.from, + expectedStyle: getClaudeDesktopTraeProcessStyle(config), + route: { + from: route.from, + profileId: route.profileId, + profileProtocol: profile.protocol || '', + targetUrl: profile.targetUrl || '', + isTraeOfficial: isTraeOfficialProfile(profile) || isTraeOfficialTarget(profile.targetUrl), + }, + }; + }; +} + +module.exports = { appendClaudeMessagesPath, createClaudeSelfTestTargetResolver }; diff --git a/lib/proxy/config.js b/lib/proxy/config.js index 77524176..02881f6e 100644 --- a/lib/proxy/config.js +++ b/lib/proxy/config.js @@ -1,8 +1,11 @@ const fs = require('fs'); const path = require('path'); const secretCrypto = require('./secret-crypto'); +const { validateConfig } = require('./validator'); +const { mergeBuiltinModelRoutes } = require('./builtin-model-routes'); -const { encryptKey, decryptKey } = secretCrypto; +const { encryptKey, decryptKey, decryptKeysStrict, SECRET_DECRYPTION_ERROR_CODE } = secretCrypto; +const emittedConfigWarnings = new Set(); const PROJECT_ROOT = path.resolve(__dirname, '..', '..'); const LEGACY_DATA_DIR = process.env.PROXY_DATA_DIR || ''; @@ -157,7 +160,8 @@ const DEFAULT_CONFIG = { ], modelRoutes: [], appSettings: { - conversationAuditEnabled: true, + conversationAuditEnabled: false, + conversationAuditCaptureContent: false, verifyUpstreamTls: true, }, entryAuth: { @@ -233,10 +237,6 @@ function isEncryptedSecret(value) { ); } -function decryptSecretValue(value) { - return isEncryptedSecret(value) ? decryptKey(value) : value; -} - function encryptSecretValue(value) { if (!value || typeof value !== 'string') return value || ''; return isEncryptedSecret(value) ? value : encryptKey(value); @@ -283,44 +283,63 @@ function resolveProfileProtocolVersion(profile) { return PROTOCOL_VERSION_DEFAULTS[protocol] || ''; } -function loadConfig() { - try { - if (!fs.existsSync(CONFIG_FILE)) return DEFAULT_CONFIG; - const rawContent = fs.readFileSync(CONFIG_FILE, 'utf8'); - const config = JSON.parse(rawContent); - - // Decrypt secrets in memory (supports hpm-dpapi: and legacy hpm-enc:) - if (config && Array.isArray(config.profiles)) { - for (const profile of config.profiles) { - if (profile && typeof profile.apiKey === 'string') { - profile.apiKey = decryptSecretValue(profile.apiKey); - } - } +function decryptConfigSecretsInPlace(config) { + const secretSlots = []; + function addSecretSlot(owner, key) { + if (owner && typeof owner[key] === 'string') secretSlots.push({ owner, key }); + } + + if (config && Array.isArray(config.profiles)) { + for (const profile of config.profiles) { + addSecretSlot(profile, 'apiKey'); } - if (config && config.claudeDesktop) { - for (const key of ['token', 'directApiKey']) { - if (typeof config.claudeDesktop[key] === 'string') { - config.claudeDesktop[key] = decryptSecretValue(config.claudeDesktop[key]); - } - } + } + if (config && config.claudeDesktop) { + for (const key of ['token', 'directApiKey']) { + addSecretSlot(config.claudeDesktop, key); } - if (config && config.mcpHub && Array.isArray(config.mcpHub.servers)) { - for (const server of config.mcpHub.servers) { - if (server && typeof server.bearerToken === 'string') { - server.bearerToken = decryptSecretValue(server.bearerToken); - } - } + } + if (config && config.mcpHub && Array.isArray(config.mcpHub.servers)) { + for (const server of config.mcpHub.servers) { + addSecretSlot(server, 'bearerToken'); } - if (config && config.entryAuth && Array.isArray(config.entryAuth.keys)) { - for (const entryKey of config.entryAuth.keys) { - if (entryKey && typeof entryKey.key === 'string') { - entryKey.key = decryptSecretValue(entryKey.key); - } - } + } + if (config && config.entryAuth && Array.isArray(config.entryAuth.keys)) { + for (const entryKey of config.entryAuth.keys) { + addSecretSlot(entryKey, 'key'); } + } - return config; + const decryptedValues = decryptKeysStrict(secretSlots.map(({ owner, key }) => owner[key])); + secretSlots.forEach(({ owner, key }, index) => { + owner[key] = decryptedValues[index]; + }); + return config; +} + +function validateLoadedConfig(config, options = {}) { + const validation = validateConfig(config); + if (!validation.valid) { + throw new Error('Invalid proxy configuration: ' + validation.errors.join(', ')); + } + if (options.logWarnings !== false) { + for (const warning of validation.warnings || []) { + if (emittedConfigWarnings.has(warning)) continue; + emittedConfigWarnings.add(warning); + console.warn('[config] Security warning:', warning); + } + } + return config; +} + +function loadConfig(options = {}) { + try { + if (!fs.existsSync(CONFIG_FILE)) return DEFAULT_CONFIG; + const rawContent = fs.readFileSync(CONFIG_FILE, 'utf8'); + const config = mergeBuiltinModelRoutes(decryptConfigSecretsInPlace(JSON.parse(rawContent))); + return validateLoadedConfig(config, options); } catch (err) { + if (options.throwOnError === true || err.code === SECRET_DECRYPTION_ERROR_CODE) throw err; console.warn('[config] Failed to read proxy.config.json:', err.message); return DEFAULT_CONFIG; } @@ -533,4 +552,6 @@ module.exports = { parseListenPortValue, encryptKey, decryptKey, + decryptConfigSecretsInPlace, + validateLoadedConfig, }; diff --git a/lib/proxy/conversation-audit-utils.js b/lib/proxy/conversation-audit-utils.js index 09b3512c..43f02a80 100644 --- a/lib/proxy/conversation-audit-utils.js +++ b/lib/proxy/conversation-audit-utils.js @@ -41,11 +41,12 @@ function createConversationAuditData(options) { entryProtocol, requestHeaders, redactHeaders, + captureContent, } = options || {}; const safeRedactHeaders = typeof redactHeaders === 'function' ? redactHeaders : (headers) => ({ ...(headers || {}) }); - return { + const auditData = { id, startTime, method, @@ -71,6 +72,11 @@ function createConversationAuditData(options) { cost: null, response: { role: 'assistant', content: '', tool_calls: [] }, }; + Object.defineProperty(auditData, '__captureContent', { + value: captureContent === true, + enumerable: false, + }); + return auditData; } function profileProtocol(prepared, isGeminiProfile, isClaudeProfile) { @@ -93,6 +99,14 @@ function populateAuditDataFromPrepared( auditData.profileId = profile.id || ''; auditData.profileName = profile.name || profile.id || ''; auditData.protocol = requestProtocol; + if (!auditData.request || typeof auditData.request !== 'object') { + auditData.request = { headers: {}, body: '', truncated: false }; + } + auditData.request.bodyBytes = Buffer.byteLength( + prepared.buffer || requestBuffer || Buffer.alloc(0) + ); + + if (auditData.__captureContent === false) return; try { const sourceBuffer = prepared.buffer || requestBuffer || Buffer.alloc(0); @@ -119,6 +133,7 @@ function installConversationAuditResponseCapture(clientRes, options) { appendAuditRecord, config, log, + captureContent = true, } = options; const responseBuffer = []; let responseBytes = 0; @@ -178,12 +193,12 @@ function installConversationAuditResponseCapture(clientRes, options) { auditProviderUsage = mergeUsage(auditProviderUsage, usageFromSsePayload(data)); if (data.type === 'content_block_delta' && data.delta) { - if (data.delta.text) { + if (captureContent && data.delta.text) { const appended = appendText(sseContent, data.delta.text); sseContent = appended.value; if (appended.truncated) auditTextTruncated = true; } - if (data.delta.partial_json) { + if (captureContent && data.delta.partial_json) { const index = data.index; const existing = sseToolCallsMap.get(index) || { args: '' }; const appended = appendText(existing.args, data.delta.partial_json); @@ -196,7 +211,7 @@ function installConversationAuditResponseCapture(clientRes, options) { } } else if (data.type === 'content_block_start' && data.content_block) { const block = data.content_block; - if (block.type === 'tool_use') { + if (captureContent && block.type === 'tool_use') { const index = data.index; sseToolCallsMap.set(index, { id: block.id, @@ -208,12 +223,12 @@ function installConversationAuditResponseCapture(clientRes, options) { if (Array.isArray(data.choices) && data.choices[0] && data.choices[0].delta) { const delta = data.choices[0].delta; - if (delta.content) { + if (captureContent && delta.content) { const appended = appendText(sseContent, delta.content); sseContent = appended.value; if (appended.truncated) auditTextTruncated = true; } - if (Array.isArray(delta.tool_calls)) { + if (captureContent && Array.isArray(delta.tool_calls)) { delta.tool_calls.forEach((tc) => { const idx = tc.index; const existing = sseToolCallsMap.get(idx) || { args: '' }; @@ -252,7 +267,7 @@ function installConversationAuditResponseCapture(clientRes, options) { auditData.elapsed = Date.now() - auditData.startTime; auditData.statusCode = clientRes.statusCode || 200; - if (isStream) { + if (isStream && captureContent) { auditData.response.content = sseContent; auditData.response.truncated = auditTextTruncated; const toolCalls = []; @@ -273,15 +288,22 @@ function installConversationAuditResponseCapture(clientRes, options) { auditData.response.truncated = responseTruncated; try { const resBodyStr = Buffer.concat(responseBuffer).toString('utf8'); - auditData.response.body = resBodyStr; - auditData.response.bodyTruncated = responseTruncated; + if (captureContent) { + auditData.response.body = resBodyStr; + auditData.response.bodyTruncated = responseTruncated; + } const resJson = JSON.parse(resBodyStr); auditProviderUsage = mergeUsage( auditProviderUsage, extractUsageFromJsonPayload(resJson) ); - if (resJson.choices && resJson.choices[0] && resJson.choices[0].message) { + if ( + captureContent && + resJson.choices && + resJson.choices[0] && + resJson.choices[0].message + ) { const msg = resJson.choices[0].message; const appended = appendText('', msg.content || ''); auditData.response.content = appended.value; @@ -295,7 +317,7 @@ function installConversationAuditResponseCapture(clientRes, options) { return { id: tc.id, name: tc.function && tc.function.name, args }; }); } - } else if (Array.isArray(resJson.content)) { + } else if (captureContent && Array.isArray(resJson.content)) { resJson.content.forEach((block) => { if (block.type === 'text') { const appended = appendText(auditData.response.content, block.text || ''); diff --git a/lib/proxy/core-request-handler.js b/lib/proxy/core-request-handler.js new file mode 100644 index 00000000..c53cc886 --- /dev/null +++ b/lib/proxy/core-request-handler.js @@ -0,0 +1,982 @@ +const http = require('http'); +const https = require('https'); + +function createCoreRequestHandler(dependencies = {}) { + const { + AUDIT_MAX_CAPTURE_BYTES, + AUDIT_MAX_TEXT_CHARS, + MAX_REQUEST_BODY_BYTES, + appendAuditRecord, + applyCliSessionModelOverride, + applyManagedAuthHeaders, + authorizeEntryRequest, + buildOutgoingHeaders, + captureRequestBody, + collectLimitedRequestBody, + createConversationAuditData, + decryptKey, + dispatchLocalManagementRoute, + entryLabel, + estimateCost, + estimateUsageForRecord, + extractUsageFromJsonPayload, + flushClaudeSseFilter, + forceChatEntry, + forceClaudeEntry, + forceResponsesEntry, + getClaudeDesktopTraeProcessStyle, + getClientIp, + getProfiles, + handleClaudeMessagesToOpenAIChat, + handleClaudeMessagesToOpenAIResponses, + handleCountTokens, + handleGeminiMessages, + handleLocalModelsRequest, + handleOpenAIChatToClaudeMessages, + handleOpenAIChatToGeminiMessages, + handleOpenAIChatToOpenAIResponses, + handleOpenAIResponsesToClaudeMessages, + handleOpenAIResponsesToGeminiMessages, + handleOpenAIResponsesToOpenAIChat, + handleTraeOfficialChatCompletion, + handleTraeOfficialClaudeMessages, + handleTraeOfficialResponses, + installConversationAuditResponseCapture, + installLocalManagementRequestEndLogger, + installManagementCorsGuard, + isAllowedEntryPath, + isAuthorizedClaudeDesktopRequest, + isClaudeDesktop3pRequest, + isClaudeDesktopRequest, + isClaudeMessagesPath, + isClaudeProfile, + isConversationRequestPath, + isEntryHealthPath, + isGeminiProfile, + isLocalManagementPath, + isOpenAIChatCompletionsPath, + isOpenAIProfile, + isOpenAIResponsesPath, + isPublicLocalManagementPath, + isQuietLocalPollRequest, + isTauriManagementOrigin, + loadConfig, + log, + logModelMapping, + logProxyEvent, + logRequestEnd, + logUsage, + managementHandlers, + mcpHub, + mergeUsage, + metricsInstance, + modelsFormatForEntry, + parseModelsRequestPath, + parsePortOrDefault, + parseTargetUrl, + populateAuditDataFromPrepared, + prepareRequestBufferWithResolver, + processClaudeSseChunk, + profileMatchesEntryKey, + redactHeaders, + resolveClaudeDesktopRoute, + resolveCliSession, + resolveOfficialCredentials, + resolveProfileProtocolVersion, + resolveRoute, + resolveUpstreamRecovery, + routesForEntry, + sampleRequestToolResults, + sampleResponseChunk, + sendEntryHealth, + sendEntryPathError, + sendJsonError, + sendRequestTooLarge, + sendUnauthorizedClaudeDesktop, + shouldConvertClaudeMessagesToOpenAI, + shouldConvertResponsesToOpenAIChat, + stripClaudeDesktopPrefix, + stripEntryAuthHeaders, + usageFromSsePayload, + getRuntimeState, + } = dependencies; + if (typeof getRuntimeState !== 'function') { + throw new TypeError('createCoreRequestHandler requires getRuntimeState'); + } + + function handleRequestInner(clientReq, clientRes, entry) { + const { + ACTIVE_PROFILE, + CLAUDE_DESKTOP_ROUTES, + CLI_SESSIONS, + CONFIG, + MANAGEMENT_TOKEN, + MODEL_ROUTES, + PROFILES, + globalHttpAgent, + globalHttpsAgent, + } = getRuntimeState(); + const startTime = Date.now(); + const clientIp = getClientIp(clientReq); + const urlPath = clientReq.url; + const method = clientReq.method; + const desktopRequest = isClaudeDesktopRequest(entry, urlPath); + const effectiveUrlPath = desktopRequest ? stripClaudeDesktopPrefix(urlPath) : urlPath; + const effectiveUrlPathname = effectiveUrlPath.split('?')[0]; + const localManagementRequest = isLocalManagementPath(effectiveUrlPathname); + // 指标埋点:每个非本地管理请求递增 + const isApiRequest = isConversationRequestPath(method, effectiveUrlPath); + if (!localManagementRequest) { + metricsInstance.counter('proxy_requests_total').inc(); + } + const auditEnabled = + isApiRequest && + process.env.PROXY_DISABLE_CONVERSATION_AUDIT !== '1' && + !(CONFIG.appSettings && CONFIG.appSettings.conversationAuditEnabled === false); + + if (auditEnabled) { + const auditCaptureContent = + CONFIG.appSettings && CONFIG.appSettings.conversationAuditCaptureContent === true; + const requestId = 'req-' + Date.now() + '-' + Math.random().toString(36).substring(2, 8); + clientRes.__auditId = requestId; + clientRes.__auditData = createConversationAuditData({ + id: requestId, + startTime: Date.now(), + method, + path: urlPath, + clientIp, + entryPort: entry && entry.listenPort, + entryProtocol: entryLabel(entry), + requestHeaders: clientReq.headers, + redactHeaders, + captureContent: auditCaptureContent, + }); + + installConversationAuditResponseCapture(clientRes, { + maxCaptureBytes: AUDIT_MAX_CAPTURE_BYTES, + maxTextChars: AUDIT_MAX_TEXT_CHARS, + redactHeaders, + mergeUsage, + usageFromSsePayload, + extractUsageFromJsonPayload, + estimateUsageForRecord, + logUsage, + estimateCost, + appendAuditRecord, + config: CONFIG, + log, + captureContent: auditCaptureContent, + }); + } + + const { cliSession } = resolveCliSession(clientReq.headers, CLI_SESSIONS); + if (cliSession) applyManagedAuthHeaders(clientReq.headers); + + const localPollRequest = isQuietLocalPollRequest(method, effectiveUrlPathname); + if (!localPollRequest) { + log.info('-> ' + method + ' ' + urlPath + ' [' + clientIp + '] entry=' + entryLabel(entry)); + logProxyEvent('request_start', { + reqId: clientReq.reqId, + method, + path: urlPath, + clientIp, + entryProtocol: entryLabel(entry), + entryPort: entry && entry.listenPort, + claudeDesktop: desktopRequest, + }); + } + + if (localManagementRequest && !installManagementCorsGuard(clientReq, clientRes)) { + logRequestEnd(method, urlPath, startTime, 403, { type: 'management_origin' }); + return; + } + + // CORS preflight + if (method === 'OPTIONS') { + clientRes.writeHead(204, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': '*', + 'Access-Control-Max-Age': '86400', + }); + clientRes.end(); + return; + } + + // Local Admin APIs + if (localManagementRequest && !isPublicLocalManagementPath(effectiveUrlPathname)) { + installLocalManagementRequestEndLogger(clientRes, method, urlPath, startTime); + + const clientToken = + clientReq.headers['x-mgmt-token'] || + new URL(clientReq.url, 'http://localhost').searchParams.get('token'); + if (clientToken !== MANAGEMENT_TOKEN && !isTauriManagementOrigin(clientReq)) { + console.warn( + `[mgmt] Unauthorized management request from ${clientIp}: ${method} ${urlPath}` + ); + clientRes.writeHead(401, { 'Content-Type': 'application/json' }); + clientRes.end(JSON.stringify({ error: 'Unauthorized management access' })); + logRequestEnd(method, urlPath, startTime, 401, { type: 'management_unauthorized' }); + return; + } + } + if ( + dispatchLocalManagementRoute({ + method, + pathname: effectiveUrlPathname, + req: clientReq, + res: clientRes, + handlers: managementHandlers, + }) + ) { + return; + } + + // Forward request - pass path as-is (SDK already includes /v1 from base_url) + // Normalize path: tools may or may not include /v1 prefix + if ( + (method === 'GET' || method === 'HEAD') && + (effectiveUrlPathname === '/favicon.ico' || effectiveUrlPathname === '/v1/favicon.ico') + ) { + clientRes.writeHead(204, { + 'Cache-Control': 'public, max-age=86400', + 'access-control-allow-origin': '*', + }); + clientRes.end(); + return; + } + + const clientPath = + effectiveUrlPath.indexOf('/v1') === 0 ? effectiveUrlPath : '/v1' + effectiveUrlPath; + const entryAuth = authorizeEntryRequest(clientReq, entry, CONFIG); + if (!entryAuth.ok) { + sendJsonError(clientRes, 401, { + error: { + type: 'authentication_error', + message: 'Invalid or missing proxy entry API key.', + }, + }); + logRequestEnd(method, urlPath, startTime, 401, { type: 'entry_auth' }); + return; + } + clientReq.entryAuth = entryAuth; + if (isEntryHealthPath(entry, method, clientPath)) { + sendEntryHealth(clientRes, entry, method, clientPath); + logRequestEnd(method, urlPath, startTime, 200, { + type: 'entry_health', + entryProtocol: entryLabel(entry), + }); + return; + } + if (!isAllowedEntryPath(entry, method, clientPath)) { + sendEntryPathError(clientRes, entry, method, clientPath); + logRequestEnd(method, urlPath, startTime, 404, { + type: 'entry_path_mismatch', + entryProtocol: entryLabel(entry), + }); + return; + } + + if ( + desktopRequest && + !isAuthorizedClaudeDesktopRequest(CONFIG, clientReq.headers, decryptKey) + ) { + sendUnauthorizedClaudeDesktop(clientRes); + logRequestEnd(method, urlPath, startTime, 401, { type: 'claude_desktop_auth' }); + return; + } + + const modelRequest = parseModelsRequestPath(effectiveUrlPath); + if ((method === 'GET' || method === 'HEAD') && modelRequest) { + const entryRoutes = routesForEntry(entry, desktopRequest, { + modelRoutes: MODEL_ROUTES, + claudeDesktopRoutes: CLAUDE_DESKTOP_ROUTES, + }).filter( + (route) => + !entryAuth.enabled || profileMatchesEntryKey(entryAuth, { id: route && route.profileId }) + ); + const modelsResult = handleLocalModelsRequest( + clientReq, + clientRes, + modelRequest, + entryRoutes, + desktopRequest ? 'claude' : modelsFormatForEntry(entry), + desktopRequest ? 'claude' : entry && entry.protocol + ); + logRequestEnd(method, urlPath, startTime, modelsResult && modelsResult.status, { + type: 'local_models', + format: modelsResult && modelsResult.format, + modelCount: modelsResult && modelsResult.modelCount, + }); + return; + } + const fwdHeaders = { ...clientReq.headers }; + delete fwdHeaders['host']; + if (entryAuth.enabled) stripEntryAuthHeaders(fwdHeaders); + + collectLimitedRequestBody(clientReq, { + limit: MAX_REQUEST_BODY_BYTES, + onTooLarge({ bytes, limit }) { + logProxyEvent('request_too_large', { + method, + path: urlPath, + bytes, + limit, + }); + }, + onComplete({ tooLarge, bytes, buffer: requestBuffer }) { + if (tooLarge) { + sendRequestTooLarge(clientReq, clientRes, MAX_REQUEST_BODY_BYTES); + logRequestEnd(method, urlPath, startTime, 413, { + type: 'request_too_large', + bytes, + limit: MAX_REQUEST_BODY_BYTES, + }); + return; + } + if (clientRes.__auditData) { + const captured = captureRequestBody(requestBuffer, clientReq.headers['content-encoding']); + clientRes.__auditData.request = { + ...clientRes.__auditData.request, + body: captured.body, + truncated: captured.truncated, + originalLength: captured.originalLength, + }; + } + const desktop3pRequest = isClaudeDesktop3pRequest(clientReq.headers); + const shouldUseDesktopRoutes = desktopRequest || desktop3pRequest; + const initialPrepared = prepareRequestBufferWithResolver( + requestBuffer, + clientReq.headers['content-encoding'], + shouldUseDesktopRoutes ? resolveClaudeDesktopRoute : resolveRoute + ); + if (method === 'POST' && clientPath.indexOf('/v1/messages/count_tokens') === 0) { + populateAuditDataFromPrepared(clientRes, initialPrepared, requestBuffer); + handleCountTokens(clientReq, clientRes, requestBuffer, logRequestEnd, startTime); + return; + } + + function doForward(currentPrepared, attempt = 1) { + const MAX_RETRIES = 2; // Up to 3 total attempts + const requestProtocol = isGeminiProfile(currentPrepared.profile) + ? 'gemini' + : isClaudeProfile(currentPrepared.profile) + ? 'claude' + : 'openai'; + // 协议版本透传:Claude 走 anthropic-version header + const protocolVersion = resolveProfileProtocolVersion(currentPrepared.profile); + if (protocolVersion) { + // 通过 out-of-band 变量携带,稍后在 outgoingHeaders 合并 + currentPrepared._protocolVersion = protocolVersion; + } + + if (clientRes.__auditData) { + populateAuditDataFromPrepared(clientRes, currentPrepared, requestBuffer); + } + if ( + clientReq.entryAuth && + clientReq.entryAuth.enabled && + !profileMatchesEntryKey(clientReq.entryAuth, currentPrepared.profile) + ) { + sendJsonError(clientRes, 403, { + error: { + type: 'permission_denied', + message: 'Proxy entry API key is not allowed to use this route.', + }, + }); + logRequestEnd(method, urlPath, startTime, 403, { type: 'entry_auth_profile' }); + return; + } + + if ( + currentPrepared.authMode === 'official-login' && + currentPrepared.authProvider === 'trae' + ) { + if (method === 'POST' && isOpenAIChatCompletionsPath(clientPath)) { + handleTraeOfficialChatCompletion( + clientReq, + clientRes, + requestBuffer, + currentPrepared, + startTime, + urlPath + ); + return; + } + if (method === 'POST' && isOpenAIResponsesPath(clientPath)) { + handleTraeOfficialResponses( + clientReq, + clientRes, + requestBuffer, + currentPrepared, + startTime, + urlPath + ); + return; + } + if (method === 'POST' && isClaudeMessagesPath(clientPath)) { + handleTraeOfficialClaudeMessages( + clientReq, + clientRes, + requestBuffer, + currentPrepared, + startTime, + urlPath, + { + claudeProcessStyle: shouldUseDesktopRoutes + ? getClaudeDesktopTraeProcessStyle(CONFIG, { + claudeDesktop3p: desktop3pRequest, + }) + : '', + } + ); + return; + } + sendJsonError(clientRes, 501, { + error: { + type: 'not_implemented', + message: + 'Trae 官方登录桥接目前支持 OpenAI /v1/chat/completions、OpenAI /v1/responses 和 Claude /v1/messages。', + }, + }); + logRequestEnd(method, urlPath, startTime, 501, { + type: 'trae_official_unsupported_path', + }); + return; + } + + const target = parseTargetUrl(currentPrepared.profile.targetUrl); + const upstreamPath = target.path + clientPath.replace(/^\/v1/, ''); + const apiKey = + currentPrepared.authMode === 'official-login' + ? currentPrepared.profile.apiKey || null + : process.env.API_KEY || currentPrepared.profile.apiKey || null; + const isClaudeEntryRequest = forceClaudeEntry(entry) && isClaudeMessagesPath(clientPath); + const isChatEntryRequest = + forceChatEntry(entry) && isOpenAIChatCompletionsPath(clientPath); + const isResponsesEntryRequest = + forceResponsesEntry(entry) && isOpenAIResponsesPath(clientPath); + + if ( + currentPrepared.authMode === 'official-login' && + currentPrepared.authProvider === 'google' && + isGeminiProfile(currentPrepared.profile) && + !(typeof apiKey === 'string' && apiKey.startsWith('ya29.')) + ) { + sendJsonError(clientRes, 401, { + error: { + type: 'authentication_error', + message: + 'Google official-login credentials are unavailable or expired. Please sign in again before using this route.', + }, + }); + logRequestEnd(method, urlPath, startTime, 401, { + type: 'google_official_auth_missing', + }); + return; + } + + const isResponsesUpstream = + currentPrepared.profile && + (currentPrepared.profile.api === 'openai-codex-responses' || + (currentPrepared.profile.targetUrl && + (currentPrepared.profile.targetUrl.includes('/backend-api/codex') || + currentPrepared.profile.targetUrl.includes('/backend-api')))); + + // 协议转换路由表:把 8 个 if 块(原 270 行)合并成数据驱动的调度。 + // 每条路由包含:匹配谓词、logModelMapping 标签、压缩请求体拒绝消息、目标 handler。 + // 第一个匹配的路由会处理并 return,后续路由不再评估(与原代码语义一致)。 + const conversionRoutes = [ + { + label: 'chat->responses', + match: + isResponsesUpstream && isOpenAIChatCompletionsPath(clientPath) && method === 'POST', + rejectMessage: + 'Chat to Responses conversion does not support compressed request bodies', + handler: handleOpenAIChatToOpenAIResponses, + }, + { + label: 'claude->responses', + match: isResponsesUpstream && isClaudeEntryRequest && method === 'POST', + rejectMessage: + 'Claude to Responses conversion does not support compressed request bodies', + handler: handleClaudeMessagesToOpenAIResponses, + }, + { + label: 'claude->openai', + match: + method === 'POST' && + (shouldConvertClaudeMessagesToOpenAI( + clientReq, + currentPrepared.profile, + clientPath + ) || + (isClaudeEntryRequest && isOpenAIProfile(currentPrepared.profile))), + rejectMessage: + 'Claude to OpenAI conversion does not support compressed request bodies', + handler: handleClaudeMessagesToOpenAIChat, + }, + { + label: 'openai->gemini', + match: + isChatEntryRequest && isGeminiProfile(currentPrepared.profile) && method === 'POST', + rejectMessage: + 'OpenAI to Gemini conversion does not support compressed request bodies', + handler: handleOpenAIChatToGeminiMessages, + }, + { + label: 'openai->claude', + match: + isClaudeProfile(currentPrepared.profile) && + method === 'POST' && + isOpenAIChatCompletionsPath(clientPath), + rejectMessage: + 'OpenAI to Claude conversion does not support compressed request bodies', + handler: handleOpenAIChatToClaudeMessages, + }, + { + label: 'responses->claude', + match: + isResponsesEntryRequest && + method === 'POST' && + isClaudeProfile(currentPrepared.profile), + rejectMessage: + 'OpenAI Responses conversion does not support compressed request bodies', + handler: handleOpenAIResponsesToClaudeMessages, + }, + { + label: 'responses->gemini', + match: + isResponsesEntryRequest && + method === 'POST' && + isGeminiProfile(currentPrepared.profile), + rejectMessage: + 'OpenAI Responses conversion does not support compressed request bodies', + handler: handleOpenAIResponsesToGeminiMessages, + }, + { + label: 'responses->chat', + match: + isResponsesEntryRequest && + method === 'POST' && + isOpenAIProfile(currentPrepared.profile) && + shouldConvertResponsesToOpenAIChat(currentPrepared.profile), + rejectMessage: null, + handler: handleOpenAIResponsesToOpenAIChat, + }, + { + label: 'claude->gemini', + match: + isGeminiProfile(currentPrepared.profile) && + method === 'POST' && + isClaudeMessagesPath(clientPath), + rejectMessage: 'Gemini conversion does not support compressed request bodies', + handler: handleGeminiMessages, + }, + ]; + + function tryConversionRoute(route) { + if (!route.match) return false; + if (route.rejectMessage && clientReq.headers['content-encoding']) { + clientRes.writeHead(400, { + 'Content-Type': 'application/json; charset=utf-8', + 'access-control-allow-origin': '*', + }); + clientRes.end( + JSON.stringify({ + error: { type: 'invalid_request_error', message: route.rejectMessage }, + }) + ); + return true; + } + logModelMapping(route.label, method, upstreamPath, currentPrepared, requestBuffer); + route.handler( + clientReq, + clientRes, + currentPrepared, + target, + apiKey, + upstreamPath, + startTime + ); + return true; + } + + for (const route of conversionRoutes) { + if (tryConversionRoute(route)) return; + } + + if (method === 'POST' && isClaudeMessagesPath(clientPath)) + sampleRequestToolResults(currentPrepared, requestProtocol, upstreamPath); + + const outgoingHeaders = buildOutgoingHeaders(fwdHeaders, { + requestProtocol, + protocolVersion: currentPrepared._protocolVersion, + authMode: currentPrepared.authMode, + apiKey, + routed: currentPrepared.routed, + bodyBuffer: currentPrepared.buffer, + }); + if (currentPrepared.routed) { + logModelMapping('route', method, upstreamPath, currentPrepared, requestBuffer); + } + + if (clientRes.__auditData) { + clientRes.__auditData.upstream = { + method, + path: upstreamPath, + targetHost: target.hostname, + targetProtocol: target.protocol, + headers: redactHeaders(outgoingHeaders), + body: captureRequestBody(currentPrepared.buffer, outgoingHeaders['content-encoding']) + .body, + }; + } + + const transport = target.protocol === 'https:' ? https : http; + const proxyReq = transport.request( + { + hostname: process.env.TARGET || target.hostname, + port: parsePortOrDefault( + process.env.TARGET_PORT || target.port, + target.protocol === 'https:' ? 443 : 80 + ), + path: upstreamPath, + method: method, + headers: outgoingHeaders, + timeout: 45000, + agent: target.protocol === 'https:' ? globalHttpsAgent : globalHttpAgent, + }, + (proxyRes) => { + const code = proxyRes.statusCode; + const recovery = resolveUpstreamRecovery({ + kind: 'status', + statusCode: code, + attempt, + maxRetries: MAX_RETRIES, + prepared: currentPrepared, + profiles: getProfiles(loadConfig()) || [], + retryAfter: proxyRes.headers['retry-after'], + }); + + if (recovery.type === 'failover') { + console.warn( + `[Failover] Upstream returned status ${code}, falling back to backup profile: ${recovery.profile.id}` + ); + const backupPrepared = { ...currentPrepared, profile: recovery.profile }; + proxyRes.destroy(); + doForward(backupPrepared, 2); + return; + } + + if (recovery.type === 'retry') { + console.warn( + `[Retry] Upstream returned ${code}, retrying in ${Math.round(recovery.delayMs)}ms... (Attempt ${attempt}/${MAX_RETRIES})` + ); + proxyRes.destroy(); + setTimeout(() => doForward(currentPrepared, attempt + 1), recovery.delayMs); + return; + } + + const headers = { + ...proxyRes.headers, + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'access-control-allow-headers': '*', + }; + + const isStream = + (proxyRes.headers['content-type'] || '').indexOf('text/event-stream') >= 0; + const shouldSanitizeOpenAIStream = + code >= 200 && + code < 300 && + isStream && + isClaudeMessagesPath(clientPath) && + isOpenAIProfile(currentPrepared.profile); + const shouldProtectStream = + code >= 200 && + code < 300 && + isStream && + isClaudeMessagesPath(clientPath) && + !isOpenAIProfile(currentPrepared.profile); + if (shouldProtectStream || shouldSanitizeOpenAIStream) + delete headers['content-length']; + clientRes.writeHead(code, headers); + const errorChunks = []; + let errorBytes = 0; + let logged = false; + + if (shouldSanitizeOpenAIStream) { + clientRes.__diagnostics = { + protocol: 'openai', + route: currentPrepared.model, + target: currentPrepared.rewriteTo, + upstreamPath, + profile: currentPrepared.profile && currentPrepared.profile.id, + }; + const sseState = { + buffer: '', + pendingClaudeTool: null, + mode: 'strip-empty-optionals', + }; + proxyRes.on('data', function (chunk) { + const chunkText = chunk.toString('utf8'); + if (!logged) logged = true; + sampleResponseChunk('openai-upstream', chunkText, { + route: currentPrepared.model, + target: currentPrepared.rewriteTo, + upstreamPath, + }); + processClaudeSseChunk(clientRes, chunkText, sseState); + }); + proxyRes.on('end', function () { + flushClaudeSseFilter(clientRes, sseState); + log.info( + 'ok ' + + method + + ' ' + + urlPath + + ' -> ' + + code + + ' (streaming sanitized, ' + + (Date.now() - startTime) + + 'ms)' + ); + clientRes.end(); + logRequestEnd(method, urlPath, startTime, code, { + type: 'openai_stream_sanitized', + }); + }); + return; + } + + if (shouldProtectStream) { + clientRes.__diagnostics = { + protocol: 'openai', + route: currentPrepared.model, + target: currentPrepared.rewriteTo, + upstreamPath, + profile: currentPrepared.profile && currentPrepared.profile.id, + }; + const sseState = { buffer: '', pendingClaudeTool: null }; + proxyRes.on('data', function (chunk) { + const chunkText = chunk.toString('utf8'); + if (!logged) logged = true; + sampleResponseChunk('openai-upstream', chunkText, { + route: currentPrepared.model, + target: currentPrepared.rewriteTo, + upstreamPath, + }); + processClaudeSseChunk(clientRes, chunkText, sseState); + }); + proxyRes.on('end', function () { + flushClaudeSseFilter(clientRes, sseState); + log.info( + 'ok ' + + method + + ' ' + + urlPath + + ' -> ' + + code + + ' (protected streaming, ' + + (Date.now() - startTime) + + 'ms)' + ); + clientRes.end(); + logRequestEnd(method, urlPath, startTime, code, { + type: 'openai_stream_protected', + }); + }); + return; + } + + proxyRes.on('data', function (chunk) { + if (!logged) { + const elapsed = Date.now() - startTime; + if (!isStream) + log.info( + 'ok ' + method + ' ' + urlPath + ' -> ' + code + ' (' + elapsed + 'ms)' + ); + logged = true; + } + + if (code >= 400 && !isStream && errorBytes < 4096) { + const remaining = 4096 - errorBytes; + const part = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk; + errorChunks.push(part); + errorBytes += part.length; + } + }); + + proxyRes.on('end', function () { + if (isStream) + log.info( + 'ok ' + + method + + ' ' + + urlPath + + ' -> ' + + code + + ' (streaming, ' + + (Date.now() - startTime) + + 'ms)' + ); + logRequestEnd(method, urlPath, startTime, code, { + type: isStream ? 'openai_stream' : 'openai', + }); + + if (code >= 400 && errorChunks.length > 0) { + const requestId = + proxyRes.headers['x-request-id'] || + proxyRes.headers['request-id'] || + proxyRes.headers['anthropic-request-id'] || + '-'; + const retryAfter = proxyRes.headers['retry-after'] || '-'; + const body = Buffer.concat(errorChunks) + .toString('utf8') + .replace(/\s+/g, ' ') + .trim(); + const requestedModel = currentPrepared.model || ''; + log.warn( + '!! upstream error ' + + method + + ' ' + + upstreamPath + + ' -> ' + + code + + ' model=' + + requestedModel + + ' request-id=' + + requestId + + ' retry-after=' + + retryAfter + + ' body=' + + body + ); + logProxyEvent('upstream_error', { + method, + path: upstreamPath, + code, + model: requestedModel, + body, + }); + } + }); + + proxyRes.pipe(clientRes); + } + ); + + proxyReq.on('timeout', function () { + const recovery = resolveUpstreamRecovery({ + kind: 'error', + attempt, + maxRetries: MAX_RETRIES, + prepared: currentPrepared, + profiles: getProfiles(loadConfig()) || [], + }); + if (recovery.type === 'failover') { + console.warn( + `[Failover] Upstream timed out, falling back to backup profile: ${recovery.profile.id}` + ); + proxyReq.destroy(); + doForward({ ...currentPrepared, profile: recovery.profile }, 2); + return; + } + if (recovery.type === 'retry') { + console.warn( + `[Retry] Upstream timeout, retrying in ${Math.round(recovery.delayMs)}ms... (Attempt ${attempt}/${MAX_RETRIES})` + ); + proxyReq.destroy(); + setTimeout(() => doForward(currentPrepared, attempt + 1), recovery.delayMs); + return; + } + proxyReq.destroy(new Error('Gateway Timeout (Upstream request timed out)')); + }); + + proxyReq.on('error', function (err) { + const recovery = resolveUpstreamRecovery({ + kind: 'error', + attempt, + maxRetries: MAX_RETRIES, + prepared: currentPrepared, + profiles: getProfiles(loadConfig()) || [], + }); + if (recovery.type === 'failover') { + console.warn( + `[Failover] Upstream request failed (${err.message}), falling back to backup profile: ${recovery.profile.id}` + ); + doForward({ ...currentPrepared, profile: recovery.profile }, 2); + return; + } + if (recovery.type === 'retry') { + console.warn( + `[Retry] Upstream error (${err.message}), retrying in ${Math.round(recovery.delayMs)}ms... (Attempt ${attempt}/${MAX_RETRIES})` + ); + setTimeout(() => doForward(currentPrepared, attempt + 1), recovery.delayMs); + return; + } + + log.error('xx ' + method + ' ' + urlPath + ' ' + (err.code || err.message)); + logProxyEvent('proxy_error', { + method, + path: urlPath, + error: err.message, + code: err.code, + }); + if (!clientRes.headersSent) { + clientRes.writeHead(502, { + 'Content-Type': 'application/json; charset=utf-8', + 'access-control-allow-origin': '*', + }); + clientRes.end( + JSON.stringify({ + error: { message: 'Proxy error: ' + err.message, type: 'proxy_error' }, + }) + ); + } + }); + + proxyReq.end(currentPrepared.buffer); + } + + applyCliSessionModelOverride(initialPrepared, { + cliSession, + requestBuffer, + requestEncoding: clientReq.headers['content-encoding'], + resolveRoute, + modelRoutes: MODEL_ROUTES, + profiles: PROFILES, + activeProfile: ACTIVE_PROFILE, + }); + + resolveOfficialCredentials(initialPrepared, clientReq.headers) + .then(async function (prepared) { + if ( + isClaudeMessagesPath(clientPath) && + prepared.buffer && + !clientReq.headers['content-encoding'] + ) { + try { + const parsed = JSON.parse(prepared.buffer.toString('utf8')); + const augmented = await mcpHub.augmentRequestTools(parsed); + prepared.buffer = Buffer.from(JSON.stringify(augmented)); + } catch (e) {} + } + doForward(prepared, 1); + }) + .catch(function (err) { + log.error('[auth] Failed to resolve credentials:', err.message); + doForward(initialPrepared, 1); + }); + }, + onAborted() { + clientRes.destroy(); + }, + }); + } + + return handleRequestInner; +} + +module.exports = { createCoreRequestHandler }; diff --git a/lib/proxy/google-official.js b/lib/proxy/google-official.js index da982e68..fdf5c3ee 100644 --- a/lib/proxy/google-official.js +++ b/lib/proxy/google-official.js @@ -5,9 +5,17 @@ const path = require('path'); const GOOGLE_OFFICIAL_CODE_ASSIST_BASE_URL = 'https://daily-cloudcode-pa.googleapis.com'; const GOOGLE_OFFICIAL_CODE_ASSIST_API_VERSION = 'v1internal'; const GOOGLE_OFFICIAL_CODE_ASSIST_USER_AGENT = 'antigravity-cli/diagnostic'; -const ANTIGRAVITY_OAUTH_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com'; +const ANTIGRAVITY_OAUTH_CLIENT_ID = + '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com'; const ANTIGRAVITY_OAUTH_CLIENT_SECRET = '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'; const GOOGLE_OFFICIAL_MODEL_IDS = [ + 'Gemini 3.6 Flash (Medium)', + 'Gemini 3.6 Flash (High)', + 'Gemini 3.6 Flash (Low)', + 'gemini-3.6-flash', + 'gemini-3.6-flash-medium', + 'gemini-3.6-flash-high', + 'gemini-3.6-flash-low', 'Gemini 3.5 Flash (Medium)', 'Gemini 3.5 Flash (High)', 'Gemini 3.5 Flash (Low)', @@ -34,11 +42,14 @@ const GOOGLE_OFFICIAL_MODEL_IDS = [ 'gemma-4-26b-a4b-it', 'Claude Sonnet 4.6 (Thinking)', 'Claude Opus 4.6 (Thinking)', - 'GPT-OSS 120B (Medium)' + 'GPT-OSS 120B (Medium)', ]; function googleOfficialCodeAssistUrl(method) { - const base = (process.env.CODE_ASSIST_ENDPOINT || GOOGLE_OFFICIAL_CODE_ASSIST_BASE_URL).replace(/\/$/, ''); + const base = (process.env.CODE_ASSIST_ENDPOINT || GOOGLE_OFFICIAL_CODE_ASSIST_BASE_URL).replace( + /\/$/, + '' + ); const version = process.env.CODE_ASSIST_API_VERSION || GOOGLE_OFFICIAL_CODE_ASSIST_API_VERSION; return `${base}/${version}:${method}`; } @@ -58,12 +69,14 @@ function buildGoogleOfficialModelsResponse() { ok: true, models: GOOGLE_OFFICIAL_MODEL_IDS.slice(), authMode: 'official-login', - source: 'official-model-config' + source: 'official-model-config', }; } function normalizeGoogleOfficialModelName(name) { - return String(name || '').replace(/^models\//, '').trim(); + return String(name || '') + .replace(/^models\//, '') + .trim(); } function uniqueModelNames(names) { @@ -79,7 +92,8 @@ function uniqueModelNames(names) { } function googleOfficialModelsFromAvailableModels(payload) { - const models = payload && payload.models && typeof payload.models === 'object' ? payload.models : {}; + const models = + payload && payload.models && typeof payload.models === 'object' ? payload.models : {}; const names = []; for (const [id, details] of Object.entries(models)) { const displayName = details && details.displayName; @@ -93,35 +107,43 @@ function googleOfficialModelsFromAvailableModels(payload) { function buildGoogleOfficialLoadCodeAssistRequest(apiKey, projectId) { const headers = { - 'accept': 'application/json', + accept: 'application/json', 'content-type': 'application/json', - 'user-agent': GOOGLE_OFFICIAL_CODE_ASSIST_USER_AGENT + 'user-agent': GOOGLE_OFFICIAL_CODE_ASSIST_USER_AGENT, }; if (apiKey) headers.authorization = `Bearer ${apiKey}`; const requestBody = JSON.stringify({ - cloudaicompanionProject: projectId || process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID || undefined, + cloudaicompanionProject: + projectId || + process.env.GOOGLE_CLOUD_PROJECT || + process.env.GOOGLE_CLOUD_PROJECT_ID || + undefined, metadata: { ideType: 'IDE_UNSPECIFIED', platform: 'PLATFORM_UNSPECIFIED', pluginType: 'GEMINI', - duetProject: projectId || process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID || undefined + duetProject: + projectId || + process.env.GOOGLE_CLOUD_PROJECT || + process.env.GOOGLE_CLOUD_PROJECT_ID || + undefined, }, - mode: 'HEALTH_CHECK' + mode: 'HEALTH_CHECK', }); return { protocol: 'gemini', fullUrl: googleOfficialCodeAssistUrl('loadCodeAssist'), method: 'POST', headers, - requestBody + requestBody, }; } function buildGoogleOfficialAvailableModelsRequest(apiKey, projectId) { const headers = { - 'accept': 'application/json', + accept: 'application/json', 'content-type': 'application/json', - 'user-agent': 'antigravity' + 'user-agent': 'antigravity', }; if (apiKey) headers.authorization = `Bearer ${apiKey}`; return { @@ -130,16 +152,20 @@ function buildGoogleOfficialAvailableModelsRequest(apiKey, projectId) { method: 'POST', headers, requestBody: JSON.stringify({ - project: projectId || process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID || undefined - }) + project: + projectId || + process.env.GOOGLE_CLOUD_PROJECT || + process.env.GOOGLE_CLOUD_PROJECT_ID || + undefined, + }), }; } function buildGoogleOfficialRetrieveUserQuotaRequest(apiKey, projectId) { const headers = { - 'accept': 'application/json', + accept: 'application/json', 'content-type': 'application/json', - 'user-agent': 'antigravity' + 'user-agent': 'antigravity', }; if (apiKey) headers.authorization = `Bearer ${apiKey}`; return { @@ -148,9 +174,13 @@ function buildGoogleOfficialRetrieveUserQuotaRequest(apiKey, projectId) { method: 'POST', headers, requestBody: JSON.stringify({ - project: projectId || process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID || undefined, - userAgent: 'antigravity' - }) + project: + projectId || + process.env.GOOGLE_CLOUD_PROJECT || + process.env.GOOGLE_CLOUD_PROJECT_ID || + undefined, + userAgent: 'antigravity', + }), }; } @@ -174,21 +204,27 @@ function formatGoogleOfficialAvailableModels(data) { const modelsObj = data && data.models && typeof data.models === 'object' ? data.models : null; if (!modelsObj) return []; const preferredByName = new Map(); - const formatted = Object.entries(modelsObj).filter(([modelId]) => { - return !/^chat_/i.test(modelId) && !/^tab_/i.test(modelId); - }).map(([modelId, item]) => { - const quota = item && item.quotaInfo ? item.quotaInfo : {}; - const remainingFraction = typeof quota.remainingFraction === 'number' ? quota.remainingFraction : null; - const remainingPercent = remainingFraction === null ? null : Math.max(0, Math.min(100, Math.round(remainingFraction * 100))); - return { - model: item && item.displayName ? item.displayName : modelId, - modelId, - percent: remainingPercent === null ? 0 : remainingPercent, - remainingPercent, - resetIn: resetTimeToDisplay(quota.resetTime), - resetTime: quota.resetTime || '' - }; - }); + const formatted = Object.entries(modelsObj) + .filter(([modelId]) => { + return !/^chat_/i.test(modelId) && !/^tab_/i.test(modelId); + }) + .map(([modelId, item]) => { + const quota = item && item.quotaInfo ? item.quotaInfo : {}; + const remainingFraction = + typeof quota.remainingFraction === 'number' ? quota.remainingFraction : null; + const remainingPercent = + remainingFraction === null + ? null + : Math.max(0, Math.min(100, Math.round(remainingFraction * 100))); + return { + model: item && item.displayName ? item.displayName : modelId, + modelId, + percent: remainingPercent === null ? 0 : remainingPercent, + remainingPercent, + resetIn: resetTimeToDisplay(quota.resetTime), + resetTime: quota.resetTime || '', + }; + }); for (const item of formatted) { const key = String(item.model || item.modelId).toLowerCase(); @@ -197,8 +233,14 @@ function formatGoogleOfficialAvailableModels(data) { preferredByName.set(key, item); continue; } - const itemScore = Number(item.modelId === key) + Number(item.modelId.includes('3.1')) + Number(item.modelId.includes('3.5')); - const existingScore = Number(existing.modelId === key) + Number(existing.modelId.includes('3.1')) + Number(existing.modelId.includes('3.5')); + const itemScore = + Number(item.modelId === key) + + Number(item.modelId.includes('3.1')) + + Number(item.modelId.includes('3.5')); + const existingScore = + Number(existing.modelId === key) + + Number(existing.modelId.includes('3.1')) + + Number(existing.modelId.includes('3.5')); if (itemScore > existingScore) preferredByName.set(key, item); } @@ -213,8 +255,12 @@ function formatGoogleOfficialAvailableModels(data) { function formatGoogleOfficialUserQuota(data) { const buckets = data && Array.isArray(data.buckets) ? data.buckets : []; return buckets.map((bucket) => { - const remainingFraction = typeof bucket.remainingFraction === 'number' ? bucket.remainingFraction : null; - const remainingPercent = remainingFraction === null ? null : Math.max(0, Math.min(100, Math.round(remainingFraction * 100))); + const remainingFraction = + typeof bucket.remainingFraction === 'number' ? bucket.remainingFraction : null; + const remainingPercent = + remainingFraction === null + ? null + : Math.max(0, Math.min(100, Math.round(remainingFraction * 100))); const model = bucket.modelId || bucket.tokenType || 'Quota'; return { model, @@ -223,7 +269,7 @@ function formatGoogleOfficialUserQuota(data) { remainingPercent, remainingAmount: bucket.remainingAmount || '', resetIn: resetTimeToDisplay(bucket.resetTime), - resetTime: bucket.resetTime || '' + resetTime: bucket.resetTime || '', }; }); } @@ -231,7 +277,7 @@ function formatGoogleOfficialUserQuota(data) { function getGoogleOfficialCredentialPaths() { return [ path.join(os.homedir(), '.gemini', 'oauth_creds.json'), - path.join(os.homedir(), '.gemini', 'antigravity-cli', 'oauth_creds.json') + path.join(os.homedir(), '.gemini', 'antigravity-cli', 'oauth_creds.json'), ]; } @@ -239,7 +285,7 @@ function antigravityStateDbPaths() { const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); return [ path.join(appData, 'Antigravity IDE', 'User', 'globalStorage', 'state.vscdb'), - path.join(appData, 'Antigravity', 'User', 'globalStorage', 'state.vscdb') + path.join(appData, 'Antigravity', 'User', 'globalStorage', 'state.vscdb'), ]; } @@ -266,7 +312,7 @@ function decodeAntigravityOAuthStateValue(value) { client_secret: ANTIGRAVITY_OAUTH_CLIENT_SECRET, expiry_date: refresh ? 1 : 0, __readOnly: true, - __source: 'antigravity-state' + __source: 'antigravity-state', }; } } @@ -282,12 +328,12 @@ function readAntigravityOAuthStateWithPython(dbPath) { 'con = sqlite3.connect("file:" + sys.argv[1] + "?mode=ro", uri=True)', 'row = con.execute("select value from ItemTable where key=?", ("antigravityUnifiedStateSync.oauthToken",)).fetchone()', 'con.close()', - 'print(row[0] if row else "")' + 'print(row[0] if row else "")', ].join('\n'); const res = cp.spawnSync('python', ['-c', script, dbPath], { encoding: 'utf8', windowsHide: true, - timeout: 3000 + timeout: 3000, }); if (res.status === 0 && res.stdout) return res.stdout.trim(); } catch (err) {} diff --git a/lib/proxy/handlers/code-assist.js b/lib/proxy/handlers/code-assist.js index eae31ae8..2beced28 100644 --- a/lib/proxy/handlers/code-assist.js +++ b/lib/proxy/handlers/code-assist.js @@ -11,19 +11,24 @@ function createCodeAssist({ fallbackId }) { function shouldUseCodeAssistUpstream(prepared, apiKey) { - const protocol = prepared && prepared.profile && prepared.profile.protocol - ? String(prepared.profile.protocol).toLowerCase() - : ''; - return Boolean(prepared - && prepared.authMode === 'official-login' - && prepared.authProvider === 'google' - && protocol === 'gemini' - && typeof apiKey === 'string' - && apiKey.startsWith('ya29.')); + const protocol = + prepared && prepared.profile && prepared.profile.protocol + ? String(prepared.profile.protocol).toLowerCase() + : ''; + return Boolean( + prepared && + prepared.authMode === 'official-login' && + prepared.authProvider === 'google' && + protocol === 'gemini' && + typeof apiKey === 'string' && + apiKey.startsWith('ya29.') + ); } function codeAssistUrl(method, stream) { - const base = (process.env.CODE_ASSIST_ENDPOINT || 'https://daily-cloudcode-pa.googleapis.com').replace(/\/$/, ''); + const base = ( + process.env.CODE_ASSIST_ENDPOINT || 'https://daily-cloudcode-pa.googleapis.com' + ).replace(/\/$/, ''); const version = process.env.CODE_ASSIST_API_VERSION || 'v1internal'; return new URL('/' + version + ':' + method + (stream ? '?alt=sse' : ''), base); } @@ -33,9 +38,21 @@ function createCodeAssist({ fallbackId }) { } function codeAssistModelSelection(model) { - const normalized = String(model || '').replace(/^google\//, '').trim(); + const normalized = String(model || '') + .replace(/^google\//, '') + .trim(); const key = normalized.toLowerCase(); const aliases = { + 'gemini 3.6 flash (medium)': 'gemini-3.6-flash', + 'gemini 3.6 flash medium': 'gemini-3.6-flash', + 'gemini-3.6-flash-medium': 'gemini-3.6-flash', + 'gemini-3.6-flash': 'gemini-3.6-flash', + 'gemini 3.6 flash (high)': 'gemini-3.6-flash', + 'gemini 3.6 flash high': 'gemini-3.6-flash', + 'gemini-3.6-flash-high': 'gemini-3.6-flash', + 'gemini 3.6 flash (low)': 'gemini-3.6-flash', + 'gemini 3.6 flash low': 'gemini-3.6-flash', + 'gemini-3.6-flash-low': 'gemini-3.6-flash', 'gemini 3.5 flash (medium)': 'gemini-3.5-flash-low', 'gemini 3.5 flash medium': 'gemini-3.5-flash-low', 'gemini-3.5-flash-medium': 'gemini-3.5-flash-low', @@ -70,11 +87,12 @@ function createCodeAssist({ fallbackId }) { function codeAssistGenerateRequest(prepared, geminiBody, targetModel, stream) { const selection = codeAssistModelSelection(targetModel); const backendModel = selection.model; - const project = prepared.googleOfficialProjectId - || (prepared.profile && prepared.profile.googleOfficialProjectId) - || process.env.GOOGLE_CLOUD_PROJECT - || process.env.GOOGLE_CLOUD_PROJECT_ID - || undefined; + const project = + prepared.googleOfficialProjectId || + (prepared.profile && prepared.profile.googleOfficialProjectId) || + process.env.GOOGLE_CLOUD_PROJECT || + process.env.GOOGLE_CLOUD_PROJECT_ID || + undefined; const request = { contents: geminiBody.contents, systemInstruction: geminiBody.systemInstruction, @@ -106,16 +124,19 @@ function createCodeAssist({ fallbackId }) { } function unwrapCodeAssistSseEvent(event) { - return String(event || '').split(/\r?\n/).map((line) => { - if (!line.startsWith('data:')) return line; - const data = line.slice(5).trim(); - if (!data || data === '[DONE]') return line; - try { - return 'data: ' + JSON.stringify(unwrapCodeAssistResponse(JSON.parse(data))); - } catch (err) { - return line; - } - }).join('\n'); + return String(event || '') + .split(/\r?\n/) + .map((line) => { + if (!line.startsWith('data:')) return line; + const data = line.slice(5).trim(); + if (!data || data === '[DONE]') return line; + try { + return 'data: ' + JSON.stringify(unwrapCodeAssistResponse(JSON.parse(data))); + } catch (err) { + return line; + } + }) + .join('\n'); } return { diff --git a/lib/proxy/handlers/management-config.js b/lib/proxy/handlers/management-config.js index 22b31971..543e6893 100644 --- a/lib/proxy/handlers/management-config.js +++ b/lib/proxy/handlers/management-config.js @@ -186,7 +186,10 @@ function createManagementConfigHandlers({ writeConfigFileAtomically(newConfig); reloadProxyConfig(); - sendJson(clientRes, 200, { ok: true }); + sendJson(clientRes, 200, { + ok: true, + ...((validation.warnings || []).length > 0 ? { warnings: validation.warnings } : {}), + }); } catch (err) { sendJson(clientRes, 400, { error: err.message }); } diff --git a/lib/proxy/handlers/management-latency.js b/lib/proxy/handlers/management-latency.js index 260e3f22..13687a4a 100644 --- a/lib/proxy/handlers/management-latency.js +++ b/lib/proxy/handlers/management-latency.js @@ -1,9 +1,13 @@ const { URL } = require('url'); const { isGoogleOfficialLoginToken } = require('../google-official'); -const { readJsonBodyFromRequest } = require('../management-utils'); +const { parseBoundedInteger, readJsonBodyFromRequest } = require('../management-utils'); const { getModelsUrl } = require('../model-list'); +const DEFAULT_LATENCY_TIMEOUT_MS = 8000; +const MIN_LATENCY_TIMEOUT_MS = 250; +const MAX_LATENCY_TIMEOUT_MS = 30000; + function latencyRequestBody(body) { if (!body || typeof body !== 'object' || Array.isArray(body)) return {}; return body; @@ -38,6 +42,12 @@ function createManagementLatencyHandlers({ const payload = latencyRequestBody(body); let { protocol, targetUrl, apiKey } = payload; const { id, profileId } = payload; + const timeoutMs = parseBoundedInteger( + payload.timeoutMs, + DEFAULT_LATENCY_TIMEOUT_MS, + MIN_LATENCY_TIMEOUT_MS, + MAX_LATENCY_TIMEOUT_MS + ); if (!targetUrl || !protocol) { throw new Error('targetUrl and protocol are required'); } @@ -69,15 +79,26 @@ function createManagementLatencyHandlers({ const startTime = Date.now(); const targetFullUrl = urlObj.protocol + '//' + urlObj.host + urlObj.pathname + urlObj.search; - const req = requestWithProxy(targetFullUrl, { method: 'GET', headers }, (res) => { - const latency = Date.now() - startTime; - res.resume(); - sendJson(clientRes, 200, { ok: true, latency }); - }); + let settled = false; + const finish = (result) => { + if (settled) return; + settled = true; + sendJson(clientRes, 200, result); + }; + const req = requestWithProxy( + targetFullUrl, + { method: 'GET', headers, timeout: timeoutMs }, + (res) => { + const latency = Date.now() - startTime; + res.resume(); + finish({ ok: true, latency }); + } + ); req.on('error', (err) => { - sendJson(clientRes, 200, { ok: false, error: err.message }); + finish({ ok: false, error: err.message }); }); + req.end(); } catch (err) { sendJson(clientRes, 400, { error: err.message }); } @@ -90,6 +111,7 @@ function createManagementLatencyHandlers({ } module.exports = { + DEFAULT_LATENCY_TIMEOUT_MS, createManagementLatencyHandlers, latencyRequestBody, }; diff --git a/lib/proxy/logger.js b/lib/proxy/logger.js index c6b49a0e..54e5aa30 100644 --- a/lib/proxy/logger.js +++ b/lib/proxy/logger.js @@ -20,6 +20,10 @@ const crypto = require('crypto'); const LEVELS = { TRACE: 10, DEBUG: 20, INFO: 30, WARN: 40, ERROR: 60, FATAL: 70, SILENT: 100 }; const levelNames = { 10: 'TRACE', 20: 'DEBUG', 30: 'INFO', 40: 'WARN', 60: 'ERROR', 70: 'FATAL' }; +const MAX_LOG_STRING_CHARS = 8192; +const MAX_LOG_DEPTH = 8; +const SENSITIVE_LOG_KEY = + /(?:authorization|proxy-authorization|(?:x-)?api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|bearer[-_]?token|cookie|password|passwd|secret|credential)/i; const _als = new AsyncLocalStorage(); @@ -51,14 +55,58 @@ function getRootLogger() { return _rootLogger; } +function sanitizeLogText(value) { + let text = String(value == null ? '' : value); + text = text.replace(/\b(Bearer|Basic)\s+[^\s,;]+/gi, '$1 [redacted]'); + text = text.replace(/([a-z][a-z0-9+.-]*:\/\/)([^/@\s]+)@/gi, '$1[redacted]@'); + text = text.replace( + /([?&](?:api[-_]?key|access[-_]?token|refresh[-_]?token|token|secret|password)=)[^&#\s]*/gi, + '$1[redacted]' + ); + text = text.replace( + /\b(api[-_]?key|access[-_]?token|refresh[-_]?token|token|secret|password)\s*[:=]\s*['"]?[^&#\s,'"}]+/gi, + '$1=[redacted]' + ); + if (text.length > MAX_LOG_STRING_CHARS) { + return text.slice(0, MAX_LOG_STRING_CHARS) + '...[truncated]'; + } + return text; +} + +function sanitizeLogValue(value, key = '', depth = 0, seen = new WeakSet()) { + if (SENSITIVE_LOG_KEY.test(String(key || ''))) return '[redacted]'; + if (value == null || typeof value === 'boolean' || typeof value === 'number') return value; + if (typeof value === 'string') return sanitizeLogText(value); + if (typeof value === 'bigint' || typeof value === 'symbol' || typeof value === 'function') { + return sanitizeLogText(String(value)); + } + if (depth >= MAX_LOG_DEPTH) return '[max-depth]'; + if (value instanceof Error) return serializeError(value); + if (typeof value !== 'object') return sanitizeLogText(String(value)); + if (seen.has(value)) return '[circular]'; + seen.add(value); + if (Array.isArray(value)) { + return value.map((item) => sanitizeLogValue(item, '', depth + 1, seen)); + } + const sanitized = {}; + for (const [childKey, childValue] of Object.entries(value)) { + sanitized[childKey] = sanitizeLogValue(childValue, childKey, depth + 1, seen); + } + return sanitized; +} + +function sanitizeLogArgs(args) { + return Array.from(args || [], (value) => sanitizeLogValue(value)); +} + function serializeError(err) { if (!err) return null; - if (typeof err === 'string') return { message: err }; + if (typeof err === 'string') return { message: sanitizeLogText(err) }; return { type: err.constructor && err.constructor.name, - message: err.message, - code: err.code, - stack: err.stack, + message: sanitizeLogText(err.message || ''), + code: sanitizeLogText(err.code || ''), + stack: sanitizeLogText(err.stack || ''), }; } @@ -74,17 +122,17 @@ function splitArgs(args) { !(args[0] instanceof Date) ) { const [first, ...rest] = args; - return [first, rest.map(formatVal).join(' ')]; + return [sanitizeLogValue(first), rest.map(formatVal).join(' ')]; } return [{}, args.map(formatVal).join(' ')]; } function formatVal(v) { if (v === null || v === undefined) return ''; - if (typeof v === 'string') return v; - if (v instanceof Error) return v.message; + if (typeof v === 'string') return sanitizeLogText(v); + if (v instanceof Error) return sanitizeLogText(v.message); try { - return JSON.stringify(v); + return JSON.stringify(sanitizeLogValue(v)); } catch (e) { return String(v); } @@ -113,12 +161,12 @@ function createLogger(module) { // Fallback to console when pino is missing (shouldn't happen in normal install) return { module, - debug: (...a) => console.log(`[${module}]`, ...a), - info: (...a) => console.log(`[${module}]`, ...a), - warn: (...a) => console.warn(`[${module}]`, ...a), - error: (...a) => console.error(`[${module}]`, ...a), - fatal: (...a) => console.error(`[${module}]`, ...a), - log: (...a) => console.log(`[${module}]`, ...a), + debug: (...a) => console.log(`[${module}]`, ...sanitizeLogArgs(a)), + info: (...a) => console.log(`[${module}]`, ...sanitizeLogArgs(a)), + warn: (...a) => console.warn(`[${module}]`, ...sanitizeLogArgs(a)), + error: (...a) => console.error(`[${module}]`, ...sanitizeLogArgs(a)), + fatal: (...a) => console.error(`[${module}]`, ...sanitizeLogArgs(a)), + log: (...a) => console.log(`[${module}]`, ...sanitizeLogArgs(a)), child: () => createLogger(module), }; } @@ -151,6 +199,12 @@ function newRequestId() { return crypto.randomBytes(6).toString('hex'); } +function normalizeRequestId(value) { + const requestId = Array.isArray(value) ? value[0] : value; + const normalized = String(requestId == null ? '' : requestId).trim(); + return /^[A-Za-z0-9._:-]{1,128}$/.test(normalized) ? normalized : newRequestId(); +} + function withRequest(context, fn) { return _als.run(context || { reqId: newRequestId() }, fn); } @@ -174,7 +228,7 @@ function getLevel() { function installReqIdMiddleware(server) { server.on('request', (req, res) => { const headerId = req.headers && (req.headers['x-request-id'] || req.headers['x-req-id']); - const reqId = headerId || newRequestId(); + const reqId = normalizeRequestId(headerId); req.reqId = reqId; res.setHeader('x-request-id', reqId); }); @@ -188,6 +242,10 @@ module.exports = { setLevel, getLevel, serializeError, + sanitizeLogText, + sanitizeLogValue, + sanitizeLogArgs, + normalizeRequestId, installReqIdMiddleware, LEVELS, levelNames, diff --git a/lib/proxy/protocol-gemini.js b/lib/proxy/protocol-gemini.js index 5655b23b..fed1f44d 100644 --- a/lib/proxy/protocol-gemini.js +++ b/lib/proxy/protocol-gemini.js @@ -315,16 +315,30 @@ function createGeminiProtocol(deps) { return result; } - function claudeMessagesToGemini(body, _model) { + function rejectsLegacySamplingParameters(model) { + const match = String(model || '') + .replace(/^models\//, '') + .trim() + .toLowerCase() + .match(/^gemini-(\d+)\.(\d+)(?:-|$)/); + if (!match) return false; + const major = Number(match[1]); + const minor = Number(match[2]); + return major > 3 || (major === 3 && minor >= 6); + } + + function claudeMessagesToGemini(body, model) { const generationConfig = {}; const maxContext = (deps.protocolOptions && deps.protocolOptions.maxContextTokens) || 0; const contextPolicy = (deps.protocolOptions && deps.protocolOptions.contextWindowPolicy) || 'overflow-drop-oldest'; if (body.max_tokens) generationConfig.maxOutputTokens = body.max_tokens; - if (body.temperature != null) generationConfig.temperature = body.temperature; - if (body.top_p != null) generationConfig.topP = body.top_p; - if (body.top_k != null) generationConfig.topK = body.top_k; + if (!rejectsLegacySamplingParameters(model)) { + if (body.temperature != null) generationConfig.temperature = body.temperature; + if (body.top_p != null) generationConfig.topP = body.top_p; + if (body.top_k != null) generationConfig.topK = body.top_k; + } if (Array.isArray(body.stop_sequences) && body.stop_sequences.length > 0) generationConfig.stopSequences = body.stop_sequences; diff --git a/lib/proxy/proxy-transport.js b/lib/proxy/proxy-transport.js new file mode 100644 index 00000000..b0838053 --- /dev/null +++ b/lib/proxy/proxy-transport.js @@ -0,0 +1,422 @@ +const { EventEmitter } = require('events'); +const http = require('http'); +const https = require('https'); +const net = require('net'); +const tls = require('tls'); +const { SocksProxyAgent } = require('socks-proxy-agent'); +const { createLogger } = require('./logger'); + +const DEFAULT_PROXY_PORTS = Object.freeze([7890, 10809, 7893, 10808]); +const DETECTION_CACHE_MS = 15_000; +const DETECTION_TIMEOUT_MS = 250; +const MAX_REQUEST_TIMEOUT_MS = 300_000; +const MAX_BUFFERED_BODY_BYTES = 10 * 1024 * 1024; + +function normalizeTimeout(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return 0; + return Math.min(Math.trunc(parsed), MAX_REQUEST_TIMEOUT_MS); +} + +function parseHttpUrl(rawUrl, label) { + let parsed; + try { + parsed = new URL(String(rawUrl)); + } catch (error) { + throw new TypeError(`${label} must be a valid URL`); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new TypeError(`${label} must use http: or https:`); + } + if (parsed.username || parsed.password) { + throw new TypeError(`${label} must not include credentials`); + } + return parsed; +} + +function getProxyAuthorization(proxy) { + if (!proxy.username && !proxy.password) return null; + const decode = (value) => { + try { + return decodeURIComponent(value); + } catch (error) { + return value; + } + }; + return ( + 'Basic ' + Buffer.from(`${decode(proxy.username)}:${decode(proxy.password)}`).toString('base64') + ); +} + +function shouldBypassProxy(target, environment = {}) { + const hostname = target.hostname.toLowerCase().replace(/^\[|\]$/g, ''); + if (hostname === 'localhost' || hostname === '::1' || hostname.startsWith('127.')) return true; + const rawNoProxy = environment.NO_PROXY || environment.no_proxy || ''; + const targetPort = target.port || (target.protocol === 'https:' ? '443' : '80'); + return String(rawNoProxy) + .split(',') + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean) + .some((entry) => { + if (entry === '*') return true; + const separator = entry.lastIndexOf(':'); + const hasPort = separator > -1 && entry.indexOf(':') === separator; + const entryHost = (hasPort ? entry.slice(0, separator) : entry).replace(/^\./, ''); + const entryPort = hasPort ? entry.slice(separator + 1) : ''; + if (entryPort && entryPort !== targetPort) return false; + return hostname === entryHost || hostname.endsWith(`.${entryHost}`); + }); +} + +function createProxyTransport({ + loadConfig, + shouldVerifyUpstreamTls = () => true, + environment = process.env, + proxyPorts = DEFAULT_PROXY_PORTS, + logger = createLogger('proxy-transport'), +} = {}) { + if (typeof loadConfig !== 'function') { + throw new TypeError('createProxyTransport requires loadConfig'); + } + + let detectedProxyUrl = null; + let detectedProxyType = 'http'; + let lastDetectTime = 0; + let proxyDetectCached = false; + + function resetLocalProxyDetectionCache() { + detectedProxyUrl = null; + detectedProxyType = 'http'; + lastDetectTime = 0; + proxyDetectCached = false; + } + + function finishDetection(callback, proxyUrl, proxyType = 'http') { + detectedProxyUrl = proxyUrl; + detectedProxyType = proxyType; + lastDetectTime = Date.now(); + proxyDetectCached = true; + callback(proxyUrl, proxyType); + } + + function detectLocalProxy(callback) { + if (typeof callback !== 'function') { + throw new TypeError('detectLocalProxy requires a callback'); + } + try { + const cfg = loadConfig(); + if (cfg?.appSettings?.disableLocalProxy) { + callback(null, 'http'); + return; + } + } catch (error) { + logger.warn({ err: error }, 'Could not read local proxy settings'); + } + + if (proxyDetectCached && Date.now() - lastDetectTime < DETECTION_CACHE_MS) { + callback(detectedProxyUrl, detectedProxyType); + return; + } + + const envProxy = + environment.HTTPS_PROXY || + environment.HTTP_PROXY || + environment.https_proxy || + environment.http_proxy; + if (envProxy) { + let parsed; + try { + parsed = new URL(envProxy); + } catch (error) { + logger.warn({ err: error }, 'Ignoring invalid proxy URL from environment'); + finishDetection(callback, null); + return; + } + const proxyType = parsed.protocol.startsWith('socks') ? 'socks5' : 'http'; + if (proxyType === 'http' && parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + logger.warn('Ignoring unsupported proxy URL protocol'); + finishDetection(callback, null); + return; + } + finishDetection(callback, parsed.toString(), proxyType); + return; + } + + const ports = Array.from(proxyPorts) + .map(Number) + .filter((port) => Number.isSafeInteger(port) && port >= 1 && port <= 65535); + if (ports.length === 0) { + finishDetection(callback, null); + return; + } + + let finished = 0; + let found = false; + const markUnavailable = () => { + finished += 1; + if (finished === ports.length && !found) finishDetection(callback, null); + }; + const markFound = (port, type) => { + if (found) return; + found = true; + const proxyUrl = `${type}://127.0.0.1:${port}`; + logger.info({ proxyType: type, port }, 'Auto-detected local proxy'); + finishDetection(callback, proxyUrl, type); + }; + + for (const port of ports) { + const socket = new net.Socket(); + let connected = false; + let settled = false; + const settleUnavailable = () => { + if (settled) return; + settled = true; + socket.destroy(); + markUnavailable(); + }; + const settleFound = (type) => { + if (settled) return; + settled = true; + socket.destroy(); + markFound(port, type); + }; + + socket.setTimeout(DETECTION_TIMEOUT_MS); + socket.once('connect', () => { + connected = true; + socket.write(Buffer.from([0x05, 0x01, 0x00])); + }); + socket.once('data', (chunk) => { + settleFound(chunk.length >= 2 && chunk[0] === 0x05 ? 'socks5' : 'http'); + }); + socket.once('end', () => (connected ? settleFound('http') : settleUnavailable())); + socket.once('close', () => { + if (!settled) connected ? settleFound('http') : settleUnavailable(); + }); + socket.once('timeout', () => (connected ? settleFound('http') : settleUnavailable())); + socket.once('error', settleUnavailable); + socket.connect(port, '127.0.0.1'); + } + } + + function requestWithProxy(targetUrl, options = {}, callback) { + const emitter = new EventEmitter(); + const requestTimeout = normalizeTimeout(options.timeout); + const bodyChunks = []; + let bufferedBytes = 0; + let payloadRequest = null; + let ended = false; + let destroyedError = null; + let fallbackTriggered = false; + + function fail(error) { + queueMicrotask(() => emitter.emit('error', error)); + } + + let target; + + function attachRequestTimeout(request) { + if (requestTimeout > 0 && typeof request.setTimeout === 'function') { + request.setTimeout(requestTimeout, () => { + request.destroy(new Error(`Request timeout (${requestTimeout}ms)`)); + }); + } + return request; + } + + function activatePayloadRequest(request, onError) { + payloadRequest = request; + emitter.activeReq = request; + request.on('error', onError || ((error) => emitter.emit('error', error))); + request.once('finish', () => emitter.emit('finish')); + for (const chunk of bodyChunks) request.write(chunk); + if (destroyedError) request.destroy(destroyedError); + else if (ended) request.end(); + } + + emitter.write = function write(chunk, encoding, callbackFn) { + if (ended) throw new Error('write after end'); + const normalized = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), encoding); + bufferedBytes += normalized.length; + if (bufferedBytes > MAX_BUFFERED_BODY_BYTES) { + const error = new RangeError('Buffered proxy request body is too large'); + emitter.destroy(error); + return false; + } + bodyChunks.push(normalized); + const accepted = payloadRequest ? payloadRequest.write(normalized) : true; + if (typeof callbackFn === 'function') queueMicrotask(callbackFn); + return accepted; + }; + emitter.end = function end(chunk, encoding, callbackFn) { + if (chunk !== undefined && chunk !== null) emitter.write(chunk, encoding); + ended = true; + if (payloadRequest) payloadRequest.end(callbackFn); + else if (typeof callbackFn === 'function') emitter.once('finish', callbackFn); + return emitter; + }; + emitter.destroy = function destroy(error = new Error('Request destroyed')) { + destroyedError = error; + if (emitter.activeReq?.destroy) emitter.activeReq.destroy(error); + else fail(error); + return emitter; + }; + + try { + target = parseHttpUrl(targetUrl, 'targetUrl'); + } catch (error) { + fail(error); + return emitter; + } + + function executeDirect() { + if (fallbackTriggered) return; + fallbackTriggered = true; + const transport = target.protocol === 'https:' ? https : http; + const request = attachRequestTimeout(transport.request(target, options, callback)); + activatePayloadRequest(request); + } + + if (shouldBypassProxy(target, environment)) { + executeDirect(); + return emitter; + } + + detectLocalProxy((proxyUrl, proxyType) => { + if (!proxyUrl) { + executeDirect(); + return; + } + + let proxy; + try { + proxy = new URL(proxyUrl); + } catch (error) { + logger.warn({ err: error }, 'Detected proxy URL is invalid; using direct connection'); + executeDirect(); + return; + } + + if (target.protocol === 'https:' && proxyType === 'socks5') { + let socksAgent; + try { + socksAgent = new SocksProxyAgent(proxyUrl, { timeout: requestTimeout || 5000 }); + } catch (error) { + logger.warn( + { err: error }, + 'SOCKS5 agent initialization failed; using direct connection' + ); + executeDirect(); + return; + } + const request = attachRequestTimeout( + https.request( + { + agent: socksAgent, + hostname: target.hostname, + port: target.port || 443, + path: `${target.pathname}${target.search}`, + method: options.method || 'GET', + headers: { ...(options.headers || {}), host: target.hostname }, + }, + callback + ) + ); + activatePayloadRequest(request, (error) => { + if (!fallbackTriggered) { + logger.warn({ err: error }, 'SOCKS5 request failed; using direct connection'); + payloadRequest = null; + executeDirect(); + } else emitter.emit('error', error); + }); + return; + } + + if (target.protocol === 'https:') { + const proxyTransport = proxy.protocol === 'https:' ? https : http; + const proxyAuthorization = getProxyAuthorization(proxy); + const connectRequest = attachRequestTimeout( + proxyTransport.request({ + hostname: proxy.hostname, + port: proxy.port, + method: 'CONNECT', + path: `${target.hostname}:${target.port || 443}`, + headers: proxyAuthorization ? { 'Proxy-Authorization': proxyAuthorization } : {}, + rejectUnauthorized: shouldVerifyUpstreamTls(), + }) + ); + emitter.activeReq = connectRequest; + connectRequest.once('connect', (response, socket) => { + if (fallbackTriggered) { + socket.destroy(); + return; + } + if (response.statusCode !== 200) { + socket.destroy(); + emitter.emit('error', new Error(`Tunnel connection failed: ${response.statusCode}`)); + return; + } + fallbackTriggered = true; + const tlsSocket = tls.connect({ + socket, + servername: target.hostname, + rejectUnauthorized: shouldVerifyUpstreamTls(), + }); + tlsSocket.once('error', (error) => emitter.emit('error', error)); + tlsSocket.once('secureConnect', () => { + const request = attachRequestTimeout( + http.request( + { + createConnection: () => tlsSocket, + method: options.method || 'GET', + path: `${target.pathname}${target.search}`, + headers: { ...(options.headers || {}), host: target.hostname }, + }, + callback + ) + ); + activatePayloadRequest(request); + }); + }); + connectRequest.once('error', (error) => { + if (!fallbackTriggered) { + logger.warn({ err: error }, 'HTTP proxy tunnel failed; using direct connection'); + executeDirect(); + } else emitter.emit('error', error); + }); + connectRequest.end(); + return; + } + + const headers = { ...(options.headers || {}), host: target.hostname }; + const proxyAuthorization = getProxyAuthorization(proxy); + if (proxyAuthorization) headers['Proxy-Authorization'] = proxyAuthorization; + const proxyTransport = proxy.protocol === 'https:' ? https : http; + const request = attachRequestTimeout( + proxyTransport.request( + { + hostname: proxy.hostname, + port: proxy.port, + path: target.toString(), + method: options.method || 'GET', + headers, + rejectUnauthorized: shouldVerifyUpstreamTls(), + }, + callback + ) + ); + activatePayloadRequest(request); + }); + + return emitter; + } + + return { detectLocalProxy, requestWithProxy, resetLocalProxyDetectionCache }; +} + +module.exports = { + createProxyTransport, + normalizeTimeout, + shouldBypassProxy, + MAX_BUFFERED_BODY_BYTES, +}; diff --git a/lib/proxy/request-observability.js b/lib/proxy/request-observability.js new file mode 100644 index 00000000..54c13715 --- /dev/null +++ b/lib/proxy/request-observability.js @@ -0,0 +1,146 @@ +function createRequestObservability(dependencies = {}) { + const { + appendAuditTextBounded, + captureRequestBody, + createClaudeFilters, + createDiagnostics, + estimateTokens, + getConfig, + getReqId, + isClaudeProfile, + isGeminiProfile, + isQuietLocalPollRequest, + logRouteModelMapping, + metricsInstance, + parseBoundedInteger, + populateAuditDataFromPreparedWithDeps, + protocolOptions, + sanitizeGeminiToolCall, + } = dependencies; + if (typeof getConfig !== 'function') { + throw new TypeError('createRequestObservability requires getConfig'); + } + + const { normalizeClaudeContent, isClaudeThinkingBlock, filterClaudeCompatibleBlocks } = + createClaudeFilters({ + protocolOptions, + makeSafeClaudeToolUseContentBlock: () => ({ type: 'text', text: '' }), + }); + + const diagnostics = createDiagnostics({ + getConfig, + normalizeClaudeContent, + sanitizeGeminiToolCall, + }); + const { logProxyEvent: writeProxyEvent } = diagnostics; + + function shouldLogProxyEvent(kind, payload) { + if (!payload) return true; + if (kind !== 'request_start' && kind !== 'request_end') return true; + const statusCode = Number(payload.statusCode || 0); + if (statusCode >= 400) return true; + return !isQuietLocalPollRequest(payload.method, payload.path); + } + + function logProxyEvent(kind, payload) { + if (!shouldLogProxyEvent(kind, payload)) return false; + writeProxyEvent(kind, payload); + return true; + } + + function logRequestEnd(method, path, startTime, statusCode, extra) { + const elapsed = Date.now() - startTime; + logProxyEvent('request_end', { + reqId: getReqId(), + method, + path, + elapsed, + statusCode, + ...(extra || {}), + }); + try { + metricsInstance.histogram('proxy_request_duration_seconds').observe(elapsed / 1000); + if (statusCode && statusCode >= 500) { + metricsInstance.counter('proxy_errors_total').inc(1, { type: extra?.type || 'unknown' }); + } + } catch (error) {} + } + + function installLocalManagementRequestEndLogger(clientRes, method, path, startTime) { + if (!clientRes || clientRes.__localManagementRequestEndLogger) return; + clientRes.__localManagementRequestEndLogger = true; + clientRes.once('finish', () => { + logRequestEnd(method, path, startTime, clientRes.statusCode || 200, { + type: 'local_management', + }); + }); + } + + const AUDIT_MAX_CAPTURE_BYTES = parseBoundedInteger( + process.env.PROXY_AUDIT_MAX_CAPTURE_BYTES, + 256 * 1024, + 16 * 1024, + 4 * 1024 * 1024 + ); + const AUDIT_MAX_TEXT_CHARS = parseBoundedInteger( + process.env.PROXY_AUDIT_MAX_TEXT_CHARS, + 120000, + 1024, + 1024 * 1024 + ); + const MAX_REQUEST_BODY_BYTES = parseBoundedInteger( + process.env.PROXY_MAX_REQUEST_BODY_BYTES, + 32 * 1024 * 1024, + 1024 * 1024, + 256 * 1024 * 1024 + ); + const MAX_CONVERSION_RESPONSE_BYTES = parseBoundedInteger( + process.env.PROXY_MAX_CONVERSION_RESPONSE_BYTES, + 16 * 1024 * 1024, + 1024 * 1024, + 256 * 1024 * 1024 + ); + + function appendAuditText(current, addition) { + return appendAuditTextBounded(current, addition, AUDIT_MAX_TEXT_CHARS); + } + + function populateAuditDataFromPrepared(clientRes, prepared, requestBuffer) { + return populateAuditDataFromPreparedWithDeps(clientRes, prepared, requestBuffer, { + isGeminiProfile, + isClaudeProfile, + }); + } + + function logModelMapping(kind, method, upstreamPath, prepared, requestBuffer) { + const estimatedContextTokens = estimateTokens( + captureRequestBody(prepared.buffer || requestBuffer).body + ); + return logRouteModelMapping( + kind, + method, + upstreamPath, + { ...prepared, estimatedContextTokens }, + logProxyEvent + ); + } + + return { + ...diagnostics, + AUDIT_MAX_CAPTURE_BYTES, + AUDIT_MAX_TEXT_CHARS, + MAX_CONVERSION_RESPONSE_BYTES, + MAX_REQUEST_BODY_BYTES, + appendAuditText, + filterClaudeCompatibleBlocks, + installLocalManagementRequestEndLogger, + isClaudeThinkingBlock, + logModelMapping, + logProxyEvent, + logRequestEnd, + normalizeClaudeContent, + populateAuditDataFromPrepared, + }; +} + +module.exports = { createRequestObservability }; diff --git a/lib/proxy/request-utils.js b/lib/proxy/request-utils.js index 62cd937c..a3102628 100644 --- a/lib/proxy/request-utils.js +++ b/lib/proxy/request-utils.js @@ -1,16 +1,36 @@ const zlib = require('zlib'); +const DEFAULT_MAX_DECOMPRESSED_BODY_BYTES = 32 * 1024 * 1024; + +function normalizeByteLimit(value, fallback = 0, min = 0, max = Number.MAX_SAFE_INTEGER) { + const parsed = Number(value); + const fallbackNumber = Number(fallback); + const safeFallback = Number.isFinite(fallbackNumber) + ? Math.min(max, Math.max(min, Math.trunc(fallbackNumber))) + : min; + if (!Number.isFinite(parsed)) return safeFallback; + return Math.min(max, Math.max(min, Math.trunc(parsed))); +} + function getClientIp(req) { const fwd = req.headers['x-forwarded-for']; if (fwd) return fwd.split(',')[0].trim(); return req.socket.remoteAddress || 'unknown'; } -function decodeBodyBuffer(buffer, encoding) { +function decodeBodyBuffer(buffer, encoding, options = {}) { + const maxOutputLength = normalizeByteLimit( + options.maxOutputLength, + DEFAULT_MAX_DECOMPRESSED_BODY_BYTES, + 1, + 256 * 1024 * 1024 + ); try { - if (encoding === 'gzip') return zlib.gunzipSync(buffer).toString('utf8'); - if (encoding === 'deflate') return zlib.inflateSync(buffer).toString('utf8'); - if (encoding === 'br') return zlib.brotliDecompressSync(buffer).toString('utf8'); + if (encoding === 'gzip') return zlib.gunzipSync(buffer, { maxOutputLength }).toString('utf8'); + if (encoding === 'deflate') + return zlib.inflateSync(buffer, { maxOutputLength }).toString('utf8'); + if (encoding === 'br') + return zlib.brotliDecompressSync(buffer, { maxOutputLength }).toString('utf8'); } catch (err) { return buffer.toString('utf8'); } @@ -36,7 +56,7 @@ function estimateTokens(value) { } function collectLimitedRequestBody(req, options) { - const limit = Math.max(0, Number((options && options.limit) || 0)); + const limit = normalizeByteLimit(options && options.limit, 0, 0, 1024 * 1024 * 1024); const onTooLarge = options && options.onTooLarge; const onComplete = options && options.onComplete; const onAborted = options && options.onAborted; @@ -72,6 +92,8 @@ function collectLimitedRequestBody(req, options) { } module.exports = { + DEFAULT_MAX_DECOMPRESSED_BODY_BYTES, + normalizeByteLimit, getClientIp, decodeBodyBuffer, extractJsonField, diff --git a/lib/proxy/runtime-config-controller.js b/lib/proxy/runtime-config-controller.js new file mode 100644 index 00000000..b28aa15e --- /dev/null +++ b/lib/proxy/runtime-config-controller.js @@ -0,0 +1,110 @@ +const { debounceReload: defaultDebounceReload } = require('./config-watcher'); + +function createRuntimeConfigController(dependencies = {}) { + const { + configFile, + createHttpsAgent, + defaultProtocolOptions, + getActiveProfile, + getClaudeDesktopRoutes, + getLocalServer, + getModelRoutes, + getProfiles, + loadConfig, + log, + metricsInstance, + protocolOptions, + rebuildProxyRouteIndex, + resetLocalProxyDetectionCache, + setHttpsAgent, + setRuntimeState, + debounceReload = defaultDebounceReload, + } = dependencies; + if (typeof loadConfig !== 'function' || typeof setRuntimeState !== 'function') { + throw new TypeError('createRuntimeConfigController requires loadConfig and setRuntimeState'); + } + + function applyConfig(nextConfig, { hotReload = false } = {}) { + const profiles = getProfiles(nextConfig); + const activeProfile = getActiveProfile(nextConfig); + const localServer = getLocalServer(nextConfig); + const modelRoutes = getModelRoutes(nextConfig); + const claudeDesktopRoutes = getClaudeDesktopRoutes(nextConfig); + const nextOptions = { + ...defaultProtocolOptions, + ...(nextConfig.protocolOptions || {}), + }; + for (const key of Object.keys(protocolOptions)) delete protocolOptions[key]; + Object.assign(protocolOptions, nextOptions); + + const httpsAgent = createHttpsAgent(); + setHttpsAgent(httpsAgent); + setRuntimeState({ + config: nextConfig, + profiles, + activeProfile, + localServer, + modelRoutes, + claudeDesktopRoutes, + }); + + if (hotReload) { + resetLocalProxyDetectionCache(); + log.info( + '[config] Configuration hot-reloaded successfully. Active profile:', + activeProfile.id + ); + try { + metricsInstance.counter('proxy_config_reloads_total').inc(); + metricsInstance.gauge('proxy_active_profile_id').set(1, { id: activeProfile.id }); + if (rebuildProxyRouteIndex(modelRoutes)) { + metricsInstance.counter('proxy_route_cache_total').inc(1, { event: 'rebuild' }); + } + } catch (error) {} + } else { + log.info('[config] Configuration reloaded (legacy). Active profile:', activeProfile.id); + } + return nextConfig; + } + + let watcher = null; + try { + watcher = debounceReload( + configFile, + (nextConfig) => { + try { + applyConfig(nextConfig, { hotReload: true }); + } catch (error) { + log.error('[config] Failed to apply hot-reload configuration:', error.message); + throw error; + } + }, + { + onError: (error) => log.warn('[config] Hot-reload error:', error?.message), + loadConfigFn: () => loadConfig({ throwOnError: true }), + } + ); + } catch (error) { + log.warn('[config] Failed to watch config file:', error.message); + } + + function reloadProxyConfig() { + if (watcher && typeof watcher.scheduleNow === 'function') { + watcher.scheduleNow(); + return; + } + try { + applyConfig(loadConfig({ throwOnError: true })); + } catch (error) { + log.error('[config] Failed to reload configuration:', error.message); + } + } + + return { + applyConfig, + close: () => watcher?.close?.(), + reloadProxyConfig, + }; +} + +module.exports = { createRuntimeConfigController }; diff --git a/lib/proxy/runtime-support.js b/lib/proxy/runtime-support.js new file mode 100644 index 00000000..4209b2f6 --- /dev/null +++ b/lib/proxy/runtime-support.js @@ -0,0 +1,130 @@ +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const util = require('util'); +const { sanitizeLogArgs } = require('./logger'); +const { parseBoundedInteger } = require('./management-utils'); + +function isTauriManagementOrigin(req) { + const origin = req?.headers?.origin ? String(req.headers.origin) : ''; + return ( + origin === 'tauri://localhost' || + origin === 'http://tauri.localhost' || + origin === 'https://tauri.localhost' + ); +} + +function maskManagementToken(token) { + const value = String(token || ''); + if (value.length <= 12) return '[redacted]'; + return value.slice(0, 6) + '...' + value.slice(-4); +} + +function createRuntimeSupport({ dataDir, initialToken = '', logger } = {}) { + if (typeof dataDir !== 'string' || !dataDir.trim()) { + throw new TypeError('createRuntimeSupport requires dataDir'); + } + let managementToken = String(initialToken || ''); + + function readExistingManagementToken(managementPort) { + if (managementToken) return managementToken; + const candidates = []; + const portNumber = Number(managementPort); + if (Number.isSafeInteger(portNumber) && portNumber >= 1 && portNumber <= 65535) { + candidates.push(path.join(dataDir, `.mgmt-token-${portNumber}`)); + } + candidates.push(path.join(dataDir, '.mgmt-token')); + for (const tokenFile of candidates) { + try { + const token = fs.readFileSync(tokenFile, 'utf8').trim(); + if (/^[0-9a-f]{32}$/i.test(token)) return token; + } catch (error) {} + } + return ''; + } + + function writeManagementTokenFile(managementPort) { + try { + managementToken = + readExistingManagementToken(managementPort) || crypto.randomBytes(16).toString('hex'); + fs.mkdirSync(dataDir, { recursive: true }); + const tokenFiles = [path.join(dataDir, '.mgmt-token')]; + const portNumber = Number(managementPort); + if (Number.isSafeInteger(portNumber) && portNumber >= 1 && portNumber <= 65535) { + tokenFiles.unshift(path.join(dataDir, `.mgmt-token-${portNumber}`)); + } + for (const tokenFile of tokenFiles) { + fs.writeFileSync(tokenFile, managementToken, { encoding: 'utf8', mode: 0o600 }); + } + } catch (error) { + logger?.warn?.({ err: error }, 'Failed to write management token file'); + } + return managementToken; + } + + function setupFileConsoleTee() { + if (process.env.PROXY_DISABLE_FILE_LOG === '1') return; + try { + fs.mkdirSync(dataDir, { recursive: true }); + const maxLogBytes = Math.max( + 256 * 1024, + parseBoundedInteger( + process.env.PROXY_LOG_MAX_BYTES, + 5 * 1024 * 1024, + 256 * 1024, + 128 * 1024 * 1024 + ) + ); + const rotateIfOversized = (filePath) => { + try { + if (!fs.existsSync(filePath) || fs.statSync(filePath).size <= maxLogBytes) return; + const rotated = filePath + '.1'; + try { + fs.unlinkSync(rotated); + } catch (error) {} + try { + fs.renameSync(filePath, rotated); + } catch (error) { + fs.writeFileSync(filePath, '', 'utf8'); + } + } catch (error) {} + }; + const logPath = path.join(dataDir, 'proxy.log'); + const errPath = path.join(dataDir, 'proxy.err.log'); + rotateIfOversized(logPath); + rotateIfOversized(errPath); + const out = fs.createWriteStream(logPath, { flags: 'a', encoding: 'utf8' }); + const err = fs.createWriteStream(errPath, { flags: 'a', encoding: 'utf8' }); + const originalLog = console.log.bind(console); + const originalWarn = console.warn.bind(console); + const originalError = console.error.bind(console); + const writeLine = (stream, args) => { + try { + stream.write(util.format(...sanitizeLogArgs(args)) + '\n'); + } catch (error) {} + }; + console.log = (...args) => { + originalLog(...sanitizeLogArgs(args)); + writeLine(out, args); + }; + console.warn = (...args) => { + originalWarn(...sanitizeLogArgs(args)); + writeLine(err, args); + }; + console.error = (...args) => { + originalError(...sanitizeLogArgs(args)); + writeLine(err, args); + }; + } catch (error) { + logger?.error?.({ err: error }, 'Failed to initialize file logging'); + } + } + + return { readExistingManagementToken, setupFileConsoleTee, writeManagementTokenFile }; +} + +module.exports = { + createRuntimeSupport, + isTauriManagementOrigin, + maskManagementToken, +}; diff --git a/lib/proxy/secret-crypto.js b/lib/proxy/secret-crypto.js index 44a899d9..f3b21a02 100644 --- a/lib/proxy/secret-crypto.js +++ b/lib/proxy/secret-crypto.js @@ -21,7 +21,12 @@ const { execFileSync } = require('child_process'); const DPAPI_PREFIX = 'hpm-dpapi:'; const AES_PREFIX = 'hpm-enc:'; const DPAPI_SCOPE = 'CurrentUser'; -const DPAPI_TIMEOUT_MS = 4000; +const DPAPI_TIMEOUT_MS = 10000; +const DPAPI_DECRYPT_ATTEMPTS = 3; +const DPAPI_BATCH_SIZE = 64; +const DPAPI_MAX_BUFFER_BYTES = 8 * 1024 * 1024; +const MAX_PROTECTED_SECRET_LENGTH = 256 * 1024; +const SECRET_DECRYPTION_ERROR_CODE = 'ERR_SECRET_DECRYPTION_FAILED'; // ===== AES-256-GCM legacy fallback ===== @@ -32,7 +37,9 @@ function getAesKey() { let secret = AES_SALT; try { secret += (os.hostname() || '') + (os.userInfo().username || ''); - } catch (e) { /* ignore */ } + } catch (e) { + /* ignore */ + } _aesKey = crypto.createHash('sha256').update(secret).digest(); return _aesKey; } @@ -82,16 +89,28 @@ function isWindows() { return process.platform === 'win32'; } -function runPowerShell(script) { +function runPowerShell(script, options = {}) { // Suppress PowerShell noise (verbose error stream) to keep our logs clean. // On non-zero exit (e.g. DPAPI failure) we return null and let caller handle it. try { - const result = execFileSync('powershell.exe', [ - '-NoProfile', - '-NonInteractive', - '-ExecutionPolicy', 'Bypass', - '-Command', `$ErrorActionPreference='SilentlyContinue';$WarningPreference='SilentlyContinue';${script}`, - ], { encoding: 'utf8', timeout: DPAPI_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'pipe'] }); + const result = execFileSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + `$ErrorActionPreference='SilentlyContinue';$WarningPreference='SilentlyContinue';${script}`, + ], + { + encoding: 'utf8', + timeout: DPAPI_TIMEOUT_MS, + maxBuffer: DPAPI_MAX_BUFFER_BYTES, + input: typeof options.input === 'string' ? options.input : undefined, + stdio: ['pipe', 'pipe', 'pipe'], + } + ); return result.trim(); } catch (err) { return null; @@ -99,7 +118,7 @@ function runPowerShell(script) { } function dpapiProbe() { - if (_dpapiAvailable !== null) return _dpapiAvailable; + if (_dpapiAvailable === true) return true; if (!isWindows()) { _dpapiAvailable = false; _dpapiDisabledReason = 'not-windows'; @@ -111,15 +130,15 @@ function dpapiProbe() { const sampleB64 = Buffer.from(sample, 'utf16le').toString('base64'); const ct = runPowerShell( `Add-Type -AssemblyName System.Security;` + - `$b=[Convert]::FromBase64String('${sampleB64}');` + - `$p=[System.Security.Cryptography.ProtectedData]::Protect($b,$null,'${DPAPI_SCOPE}');` + - `[Convert]::ToBase64String($p)`, + `$b=[Convert]::FromBase64String('${sampleB64}');` + + `$p=[System.Security.Cryptography.ProtectedData]::Protect($b,$null,'${DPAPI_SCOPE}');` + + `[Convert]::ToBase64String($p)` ); const ptB64 = runPowerShell( `Add-Type -AssemblyName System.Security;` + - `$b=[Convert]::FromBase64String('${ct}');` + - `$u=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,'${DPAPI_SCOPE}');` + - `[Convert]::ToBase64String($u)`, + `$b=[Convert]::FromBase64String('${ct}');` + + `$u=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,'${DPAPI_SCOPE}');` + + `[Convert]::ToBase64String($u)` ); if (!ct || !ptB64) throw new Error('probe produced empty output'); const pt = Buffer.from(ptB64, 'base64').toString('utf16le'); @@ -140,9 +159,9 @@ function dpapiProtect(plaintext) { const b64 = Buffer.from(plaintext, 'utf16le').toString('base64'); const result = runPowerShell( `Add-Type -AssemblyName System.Security;` + - `$b=[Convert]::FromBase64String('${b64}');` + - `$p=[System.Security.Cryptography.ProtectedData]::Protect($b,$null,'${DPAPI_SCOPE}');` + - `[Convert]::ToBase64String($p)`, + `$b=[Convert]::FromBase64String('${b64}');` + + `$p=[System.Security.Cryptography.ProtectedData]::Protect($b,$null,'${DPAPI_SCOPE}');` + + `[Convert]::ToBase64String($p)` ); if (!result) throw new Error('powershell returned empty'); return DPAPI_PREFIX + result; @@ -155,19 +174,10 @@ function dpapiProtect(plaintext) { function dpapiUnprotect(payload) { if (!payload || !payload.startsWith(DPAPI_PREFIX)) return payload; - const b64 = payload.substring(DPAPI_PREFIX.length); - if (!dpapiProbe()) return payload; try { - const result = runPowerShell( - `Add-Type -AssemblyName System.Security;` + - `$b=[Convert]::FromBase64String('${b64}');` + - `$u=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,'${DPAPI_SCOPE}');` + - `[Convert]::ToBase64String($u)`, - ); - if (!result) throw new Error('powershell returned empty'); - return Buffer.from(result, 'base64').toString('utf16le'); + return decryptKeysStrict([payload])[0]; } catch (err) { - console.warn('[crypto] DPAPI unprotect failed:', err.message); + console.warn('[crypto] DPAPI unprotect failed:', err.code || 'unprotect_failed'); return payload; } } @@ -176,6 +186,118 @@ function dpapiUnprotect(payload) { const _decryptCache = new Map(); +function secretDecryptionError(message = 'Failed to decrypt protected configuration secrets') { + const error = new Error(message); + error.code = SECRET_DECRYPTION_ERROR_CODE; + return error; +} + +function isPlausibleBase64(value) { + return ( + typeof value === 'string' && + value.length > 0 && + value.length % 4 === 0 && + /^[A-Za-z0-9+/]+={0,2}$/.test(value) + ); +} + +function unprotectDpapiBatchOnce(cipherTexts) { + if (!isWindows()) throw secretDecryptionError('DPAPI secrets require Windows CurrentUser'); + const payloads = cipherTexts.map((cipherText) => { + if (typeof cipherText !== 'string' || cipherText.length > MAX_PROTECTED_SECRET_LENGTH) { + throw secretDecryptionError(); + } + const payload = cipherText.substring(DPAPI_PREFIX.length); + if (!isPlausibleBase64(payload)) throw secretDecryptionError(); + return payload; + }); + const script = + `Add-Type -AssemblyName System.Security;` + + `$items=ConvertFrom-Json -InputObject ([Console]::In.ReadToEnd());` + + `$values=New-Object System.Collections.ArrayList;` + + `foreach($item in @($items)){` + + `try{` + + `$b=[Convert]::FromBase64String([string]$item);` + + `$u=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,'${DPAPI_SCOPE}');` + + `[void]$values.Add([Convert]::ToBase64String($u));` + + `}catch{[void]$values.Add($null)}` + + `};` + + `$result=[ordered]@{values=@($values.ToArray())};` + + `[Console]::Out.Write(($result|ConvertTo-Json -Compress));`; + const raw = runPowerShell(script, { input: JSON.stringify(payloads) }); + if (!raw) throw secretDecryptionError(); + + let parsed; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw secretDecryptionError(); + } + if (!parsed || !Array.isArray(parsed.values) || parsed.values.length !== cipherTexts.length) { + throw secretDecryptionError(); + } + return parsed.values.map((value) => { + if (typeof value !== 'string' || !isPlausibleBase64(value)) { + throw secretDecryptionError(); + } + return Buffer.from(value, 'base64').toString('utf16le'); + }); +} + +function unprotectDpapiBatch(cipherTexts) { + let lastError = null; + for (let attempt = 0; attempt < DPAPI_DECRYPT_ATTEMPTS; attempt += 1) { + try { + const values = unprotectDpapiBatchOnce(cipherTexts); + _dpapiAvailable = true; + _dpapiDisabledReason = null; + return values; + } catch (error) { + lastError = error; + } + } + _dpapiAvailable = false; + _dpapiDisabledReason = 'batch-unprotect-failed'; + throw lastError || secretDecryptionError(); +} + +function decryptKeysStrict(values) { + if (!Array.isArray(values)) throw new TypeError('decryptKeysStrict requires an array'); + const resolved = new Map(); + const pendingDpapi = []; + + for (const value of values) { + if (typeof value !== 'string' || !value) continue; + if (_decryptCache.has(value)) { + resolved.set(value, _decryptCache.get(value)); + continue; + } + if (value.startsWith(AES_PREFIX)) { + const plain = decryptAes(value); + if (!plain || plain === value) throw secretDecryptionError(); + _decryptCache.set(value, plain); + resolved.set(value, plain); + continue; + } + if (value.startsWith(DPAPI_PREFIX) && !pendingDpapi.includes(value)) { + pendingDpapi.push(value); + } + } + + for (let offset = 0; offset < pendingDpapi.length; offset += DPAPI_BATCH_SIZE) { + const batch = pendingDpapi.slice(offset, offset + DPAPI_BATCH_SIZE); + const plainValues = unprotectDpapiBatch(batch); + for (let index = 0; index < batch.length; index += 1) { + const plain = plainValues[index]; + if (!plain || plain === batch[index]) throw secretDecryptionError(); + _decryptCache.set(batch[index], plain); + resolved.set(batch[index], plain); + } + } + + return values.map((value) => resolved.get(value) || value); +} + function decryptKey(cipherText) { if (!cipherText) return cipherText; if (typeof cipherText !== 'string') return cipherText; @@ -226,8 +348,10 @@ function getCryptoBackend() { module.exports = { DPAPI_PREFIX, AES_PREFIX, + SECRET_DECRYPTION_ERROR_CODE, encryptKey, decryptKey, + decryptKeysStrict, clearDecryptCache, getCryptoBackend, // Test hooks diff --git a/lib/proxy/server-lifecycle.js b/lib/proxy/server-lifecycle.js new file mode 100644 index 00000000..46525b5e --- /dev/null +++ b/lib/proxy/server-lifecycle.js @@ -0,0 +1,253 @@ +const http = require('http'); +const https = require('https'); +const path = require('path'); + +function startProxyServers(dependencies = {}) { + const { + ACTIVE_HTTPS_SERVERS, + APP_ID, + DATA_DIR, + ensureCert, + exceptionHandlers, + handleRequest, + installManagementCorsGuard, + log, + maskManagementToken, + parsePortOrDefault, + writeManagementTokenFile, + getRuntimeState, + } = dependencies; + if (typeof getRuntimeState !== 'function') { + throw new TypeError('startProxyServers requires getRuntimeState'); + } + const runtimeState = getRuntimeState(); + const { ACTIVE_PROFILE, CONFIG, ENTRY_SERVERS, LOCAL_SERVER, MODEL_ROUTES, PORT } = runtimeState; + let { MANAGEMENT_TOKEN } = runtimeState; + + const MGMT_PORT = parsePortOrDefault( + process.env.MGMT_PORT || CONFIG.localServer?.managementPort, + PORT <= 65435 ? PORT + 100 : 3543 + ); + + const checkExistingManagementServer = function (callback) { + let settled = false; + function finish(isProxy) { + if (settled) return; + settled = true; + callback(isProxy); + } + const req = http.request( + { + host: '127.0.0.1', + port: MGMT_PORT, + path: '/v1/local/health', + method: 'GET', + timeout: 500, + }, + function (res) { + const chunks = []; + let bytes = 0; + res.on('data', function (chunk) { + if (settled) return; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8'); + bytes += buffer.length; + if (bytes > 4096) { + chunks.length = 0; + req.destroy(); + finish(false); + return; + } + chunks.push(buffer); + }); + res.on('end', function () { + if (settled) return; + try { + const body = Buffer.concat(chunks, bytes).toString('utf8'); + const parsed = JSON.parse(body || '{}'); + finish(res.statusCode >= 200 && res.statusCode < 300 && parsed.appId === APP_ID); + } catch (err) { + finish(false); + } + }); + } + ); + req.on('timeout', function () { + req.destroy(); + finish(false); + }); + req.on('error', function () { + finish(false); + }); + req.end(); + }; + + const startServers = function () { + const tlsConfig = ensureCert(); + const servers = []; + + // Wire safety-module shutdown hook to our local shutdown + try { + exceptionHandlers.setShutdownHook(function (_reason) { + shutdown(); + }); + } catch (e) {} + + function shutdown() { + let remaining = servers.length; + if (!remaining) process.exit(0); + for (const server of servers) { + try { + server.close(function () { + remaining -= 1; + if (remaining === 0) process.exit(0); + }); + } catch (err) { + remaining -= 1; + if (remaining === 0) process.exit(0); + } + } + } + + function handleListenError(serverName, err) { + if (err && err.code === 'EADDRINUSE') { + log.error( + '[startup] ' + + serverName + + ' is already in use. Stop the owning process or change the configured port.' + ); + process.exit(1); + return; + } + log.error('[startup] ' + serverName + ' failed: ' + (err && err.message ? err.message : err)); + process.exit(1); + } + + function listenEntry(entry) { + const factory = entry.tls === false ? http : https; + const server = + entry.tls === false + ? factory.createServer(function (req, res) { + handleRequest(req, res, entry); + }) + : factory.createServer(tlsConfig, function (req, res) { + handleRequest(req, res, entry); + }); + servers.push(server); + if (entry.tls !== false) ACTIVE_HTTPS_SERVERS.push(server); + const listenHost = entry.host === 'localhost' ? '127.0.0.1' : entry.host; + server.on('error', function (err) { + handleListenError('port ' + listenHost + ':' + entry.listenPort, err); + }); + server.listen(entry.listenPort, listenHost, function () { + log.info( + ' Listen: ' + + (entry.tls === false ? 'http' : 'https') + + '://' + + entry.host + + ':' + + entry.listenPort + + entry.basePath + + ' [' + + entry.protocol + + ']' + ); + }); + return server; + } + + log.info(''); + log.info('+-----------------------------------------------+'); + log.info('| OpenAI HTTPS Proxy |'); + log.info('+-----------------------------------------------+'); + log.info(''); + + const envEntry = process.env.PORT ? [{ ...LOCAL_SERVER, listenPort: PORT }] : ENTRY_SERVERS; + for (const entry of envEntry) listenEntry(entry); + + // ── HTTP 管理端口 ────────────────────────────────── + // 纯 HTTP 服务器,仅处理 /v1/local/* 管理 API,不转发代理请求。 + // 解决 Tauri 生产模式下 webview 无法访问自签名 HTTPS 的问题。 + const mgmtServer = http.createServer(function (req, res) { + const urlPath = req.url || ''; + if (!installManagementCorsGuard(req, res)) return; + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + // 只允许 /v1/local/* 管理端点 + if (urlPath.startsWith('/v1/local/') || urlPath.startsWith('/local/')) { + handleRequest(req, res, envEntry[0] || LOCAL_SERVER); + return; + } + // 拒绝其他所有请求(不允许通过 HTTP 管理端口做代理转发) + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Management port only accepts /v1/local/* requests' })); + }); + servers.push(mgmtServer); + mgmtServer.on('error', function (err) { + handleListenError('management port 127.0.0.1:' + MGMT_PORT, err); + }); + // Write the management token to disk BEFORE starting to listen so + // dev-static-server.js (and any other reader) sees the new token the + // moment the port is reachable. Without this, there's a small window + // where the server is accepting connections but the token file still + // holds the previous process's token, and any forwarded management + // request gets 401 Unauthorized until the new token is written. + MANAGEMENT_TOKEN = writeManagementTokenFile(MGMT_PORT); + mgmtServer.listen(MGMT_PORT, '127.0.0.1', function () { + writeManagementTokenFile(MGMT_PORT); + log.info(' Mgmt: http://127.0.0.1:' + MGMT_PORT + '/v1/local/* [management-http]'); + }); + + process.nextTick(function () { + log.info(''); + log.info(' Profile: ' + (ACTIVE_PROFILE.name || ACTIVE_PROFILE.id || 'default')); + log.info(' Target: ' + ACTIVE_PROFILE.targetUrl); + log.info(' Routes: ' + MODEL_ROUTES.length); + log.info(''); + log.info(' SDK Config:'); + for (const entry of envEntry) { + log.info( + ' ' + + entry.protocol + + ': ' + + (entry.tls === false ? 'http' : 'https') + + '://' + + entry.host + + ':' + + entry.listenPort + + entry.basePath + ); + } + log.info(' management: http://127.0.0.1:' + MGMT_PORT + '/v1/local/*'); + log.info(' mgmt_token_file: ' + path.join(DATA_DIR, `.mgmt-token-${MGMT_PORT}`)); + log.info(' mgmt_token_hint: ' + maskManagementToken(MANAGEMENT_TOKEN)); + log.info(' openai.api_key = "your key"'); + log.info(''); + log.info(' Ctrl+C to stop'); + log.info(''); + }); + + process.on('SIGINT', function () { + log.info('\nStopping...'); + shutdown(); + }); + process.on('SIGTERM', shutdown); + }; + + checkExistingManagementServer(function (isRunning) { + if (isRunning) { + log.info( + '[single-instance] Existing proxy management endpoint detected on 127.0.0.1:' + + MGMT_PORT + + '; exiting duplicate process.' + ); + process.exit(0); + return; + } + startServers(); + }); +} + +module.exports = { startProxyServers }; diff --git a/lib/proxy/upstream-retry.js b/lib/proxy/upstream-retry.js index 2dacc76f..c01e868d 100644 --- a/lib/proxy/upstream-retry.js +++ b/lib/proxy/upstream-retry.js @@ -1,15 +1,55 @@ +const MAX_RETRY_DELAY_MS = 120000; + function isRetryableStatusCode(statusCode) { return statusCode === 429 || statusCode >= 500; } function shouldRetryAttempt(attempt, maxRetries) { - return Number(attempt) <= Number(maxRetries); + const parsedAttempt = Number(attempt); + const parsedMaxRetries = Number(maxRetries); + return ( + Number.isInteger(parsedAttempt) && + Number.isInteger(parsedMaxRetries) && + parsedAttempt >= 1 && + parsedMaxRetries >= 0 && + parsedAttempt <= parsedMaxRetries + ); } -function retryDelayMs(attempt, random = Math.random) { +function parseRetryAfterMs(value, now = Date.now(), maxDelayMs = MAX_RETRY_DELAY_MS) { + if (value == null || value === '') return null; + const safeMaxDelay = Number.isFinite(Number(maxDelayMs)) + ? Math.max(0, Math.trunc(Number(maxDelayMs))) + : MAX_RETRY_DELAY_MS; + const text = String(value).trim(); + if (!text) return null; + + let delayMs; + if (/^\d+(?:\.\d+)?$/.test(text)) { + delayMs = Number(text) * 1000; + } else { + const timestamp = Date.parse(text); + const nowMs = Number(now); + if (!Number.isFinite(timestamp) || !Number.isFinite(nowMs)) return null; + delayMs = timestamp - nowMs; + } + if (!Number.isFinite(delayMs)) return null; + return Math.min(safeMaxDelay, Math.max(0, Math.ceil(delayMs))); +} + +function retryDelayMs(attempt, random = Math.random, retryAfter = null) { + const parsedAttempt = Number(attempt); + const safeAttempt = Number.isFinite(parsedAttempt) + ? Math.min(10, Math.max(0, Math.trunc(parsedAttempt))) + : 0; const jitter = typeof random === 'function' ? Number(random()) : 0; const safeJitter = Number.isFinite(jitter) ? Math.min(Math.max(jitter, 0), 1) : 0; - return Math.pow(2, Number(attempt) || 0) * 1000 + safeJitter * 500; + const exponentialDelay = Math.min( + MAX_RETRY_DELAY_MS, + Math.pow(2, safeAttempt) * 1000 + safeJitter * 500 + ); + const retryAfterMs = parseRetryAfterMs(retryAfter); + return retryAfterMs == null ? exponentialDelay : Math.max(exponentialDelay, retryAfterMs); } function shouldRetryUpstreamStatus(statusCode, attempt, maxRetries) { @@ -32,11 +72,40 @@ function shouldFailoverOnError(attempt, prepared, profiles) { return resolveBackupProfile(prepared, profiles); } +function resolveUpstreamRecovery(options = {}) { + const { kind, statusCode, attempt, maxRetries, prepared, profiles, retryAfter, random } = options; + + if (kind === 'status') { + const backupProfile = shouldFailoverOnStatus(statusCode, attempt, prepared, profiles); + if (backupProfile) return { type: 'failover', profile: backupProfile }; + if (shouldRetryUpstreamStatus(statusCode, attempt, maxRetries)) { + return { + type: 'retry', + delayMs: retryDelayMs(attempt, random, retryAfter), + }; + } + return { type: 'none' }; + } + + if (kind === 'error') { + const backupProfile = shouldFailoverOnError(attempt, prepared, profiles); + if (backupProfile) return { type: 'failover', profile: backupProfile }; + if (shouldRetryAttempt(attempt, maxRetries)) { + return { type: 'retry', delayMs: retryDelayMs(attempt, random) }; + } + } + + return { type: 'none' }; +} + module.exports = { + MAX_RETRY_DELAY_MS, isRetryableStatusCode, shouldRetryAttempt, + parseRetryAfterMs, retryDelayMs, shouldRetryUpstreamStatus, shouldFailoverOnStatus, shouldFailoverOnError, + resolveUpstreamRecovery, }; diff --git a/lib/proxy/validator.js b/lib/proxy/validator.js index ff4e0165..c97e3b74 100644 --- a/lib/proxy/validator.js +++ b/lib/proxy/validator.js @@ -1,8 +1,9 @@ function validateConfig(config) { const errors = []; + const warnings = []; if (!config || typeof config !== 'object') { - return { valid: false, errors: ['config must be an object'] }; + return { valid: false, errors: ['config must be an object'], warnings }; } if (!config.activeProfile) errors.push('Missing activeProfile'); @@ -10,9 +11,16 @@ function validateConfig(config) { if (!Array.isArray(config.profiles)) { errors.push('profiles must be an array'); } else { + if (config.profiles.length === 0) errors.push('profiles must contain at least one profile'); + if (config.profiles.length > 64) errors.push('profiles must contain at most 64 profiles'); const ids = new Set(); config.profiles.forEach((p, i) => { + if (!p || typeof p !== 'object' || Array.isArray(p)) { + errors.push(`Profile [${i}] must be an object`); + return; + } if (!p.id) errors.push(`Profile [${i}] is missing id`); + else if (!isSafeIdentifier(p.id)) errors.push(`Profile [${i}] has invalid id`); if (p.id && ids.has(p.id)) errors.push(`Profile [${p.id}] is duplicated`); if (p.id) ids.add(p.id); if (!p.protocol) errors.push(`Profile [${p.id || i}] is missing protocol`); @@ -21,7 +29,32 @@ function validateConfig(config) { } if (p.targetUrl && !isHttpUrl(p.targetUrl)) { errors.push(`Profile [${p.id || i}] has invalid targetUrl`); + } else if (p.targetUrl) { + const targetUrl = String(p.targetUrl); + if (targetUrl.length > 2048) errors.push(`Profile [${p.id || i}] targetUrl is too long`); + try { + const parsed = new URL(targetUrl); + if (parsed.username || parsed.password) { + errors.push(`Profile [${p.id || i}] targetUrl must not include credentials`); + } + if ( + parsed.protocol === 'http:' && + !isLoopbackHost(parsed.hostname) && + typeof p.apiKey === 'string' && + p.apiKey.length > 0 + ) { + warnings.push( + `Profile [${p.id || i}] sends credentials to a non-loopback HTTP upstream` + ); + } + } catch (err) {} } + if (p.name != null && String(p.name).length > 256) + errors.push(`Profile [${p.id || i}] name is too long`); + if (p.apiKey != null && String(p.apiKey).length > 4096) + errors.push(`Profile [${p.id || i}] apiKey is too long`); + if (p.apiKey != null && /[\r\n]/.test(String(p.apiKey))) + errors.push(`Profile [${p.id || i}] apiKey is invalid`); }); if (config.activeProfile && !ids.has(config.activeProfile)) { errors.push(`activeProfile [${config.activeProfile}] does not match any profile`); @@ -37,13 +70,31 @@ function validateConfig(config) { validateMcpHubConfig(config.mcpHub, errors); validateEntryAuthConfig(config.entryAuth, errors); + const exposedServer = [ + config.localServer, + ...(Array.isArray(config.entryServers) ? config.entryServers : []), + ] + .filter(Boolean) + .find((server) => server.host && !isLoopbackHost(server.host)); + if (exposedServer && (!config.entryAuth || config.entryAuth.enabled !== true)) { + errors.push('entryAuth.enabled must be true when a proxy entry listens on a non-loopback host'); + } return { valid: errors.length === 0, errors, + warnings, }; } +function isLoopbackHost(value) { + const host = String(value || '') + .trim() + .toLowerCase() + .replace(/^\[|\]$/g, ''); + return host === 'localhost' || host === '127.0.0.1' || host === '::1'; +} + function isHttpUrl(value) { try { const parsed = new URL(String(value)); @@ -244,6 +295,7 @@ function validateServerConfig(server, label, errors) { if (server.host && /[\s/\\]/.test(String(server.host))) { errors.push(`${label}.host is invalid`); } + if (server.host && String(server.host).length > 253) errors.push(`${label}.host is too long`); const portValue = server.listenPort == null ? server.port : server.listenPort; if (portValue != null) { const port = Number(portValue); @@ -256,4 +308,4 @@ function validateServerConfig(server, label, errors) { } } -module.exports = { validateConfig, validateMcpHubConfig, validateEntryAuthConfig }; +module.exports = { validateConfig, validateMcpHubConfig, validateEntryAuthConfig, isLoopbackHost }; diff --git a/lib/release/governance.js b/lib/release/governance.js new file mode 100644 index 00000000..21b51c07 --- /dev/null +++ b/lib/release/governance.js @@ -0,0 +1,184 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const FORBIDDEN_FILE_NAMES = new Set([ + '.env', + '.mgmt-token', + '.proxy.lock', + '.proxy.pid', + 'proxy.config.json', + 'proxy.log', + 'proxy.err.log', +]); + +const FORBIDDEN_DIRECTORY_NAMES = new Set(['.certs', '.hpm-runtime', '.claude']); +const PRIVATE_MARKERS = [ + '-----BEGIN PRIVATE KEY-----', + '-----BEGIN RSA PRIVATE KEY-----', + '-----BEGIN EC PRIVATE KEY-----', +]; +const MAX_SCANNED_TEXT_BYTES = 1024 * 1024; + +function normalizeRelativePath(value) { + return String(value || '') + .split(path.sep) + .join('/'); +} + +function isPathInside(parentPath, candidatePath) { + const parent = path.resolve(parentPath); + const candidate = path.resolve(candidatePath); + const relative = path.relative(parent, candidate); + return ( + relative !== '' && + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +function assertSafeReleasePath(repoRoot, candidatePath) { + const allowedRoot = path.join(path.resolve(repoRoot), 'dist'); + const resolved = path.resolve(candidatePath); + if (!isPathInside(allowedRoot, resolved)) { + throw new Error(`Release output must stay inside ${allowedRoot}: ${resolved}`); + } + return resolved; +} + +function collectReleaseEntries(rootDir) { + const root = path.resolve(rootDir); + const files = []; + const directories = []; + if (!fs.existsSync(root)) return { files, directories }; + + function visit(current) { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const absolute = path.join(current, entry.name); + const relative = normalizeRelativePath(path.relative(root, absolute)); + if (entry.isSymbolicLink()) { + files.push({ absolute, relative, symbolicLink: true }); + } else if (entry.isDirectory()) { + directories.push({ absolute, relative }); + visit(absolute); + } else if (entry.isFile()) { + files.push({ absolute, relative, symbolicLink: false }); + } + } + } + + visit(root); + return { files, directories }; +} + +function findSensitiveReleaseEntries(rootDir) { + const findings = []; + const { files, directories } = collectReleaseEntries(rootDir); + + for (const directory of directories) { + const name = path.basename(directory.absolute).toLowerCase(); + const belongsToDependency = directory.relative.startsWith('node_modules/'); + if (!belongsToDependency && FORBIDDEN_DIRECTORY_NAMES.has(name)) { + findings.push({ type: 'forbidden-directory', path: directory.relative }); + } + } + + for (const file of files) { + const name = path.basename(file.absolute).toLowerCase(); + if ( + file.symbolicLink || + FORBIDDEN_FILE_NAMES.has(name) || + name.startsWith('.mgmt-token-') || + name.endsWith('.log') || + name.endsWith('.jsonl') || + name.endsWith('.pem') || + name.endsWith('.key') + ) { + findings.push({ + type: file.symbolicLink ? 'symbolic-link' : 'forbidden-file', + path: file.relative, + }); + continue; + } + + const stat = fs.statSync(file.absolute); + if (stat.size <= 0 || stat.size > MAX_SCANNED_TEXT_BYTES) continue; + const extension = path.extname(name); + if (!['.json', '.txt', '.md', '.yaml', '.yml'].includes(extension)) continue; + const text = fs.readFileSync(file.absolute, 'utf8'); + if (PRIVATE_MARKERS.some((marker) => text.includes(marker))) { + findings.push({ type: 'private-key-content', path: file.relative }); + } + } + + return findings; +} + +function requiredReleasePaths(expectedDependencies, options = {}) { + const required = [ + 'openai-proxy.js', + 'lib/proxy/config.js', + 'proxy.bat', + 'proxy.ps1', + 'proxy.config.example.json', + 'package.json', + 'runtime/node/node.exe', + ]; + if (options.requireExecutable !== false) required.push('https-proxy-manager.exe'); + for (const dependency of Object.keys(expectedDependencies || {})) { + required.push(`node_modules/${dependency}/package.json`); + } + return required; +} + +function validateReleaseDirectory(rootDir, expectedDependencies, options = {}) { + const root = path.resolve(rootDir); + const errors = []; + if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) { + return { ok: false, errors: [`Release directory does not exist: ${root}`], findings: [] }; + } + + for (const relative of requiredReleasePaths(expectedDependencies, options)) { + if (!fs.existsSync(path.join(root, ...relative.split('/')))) { + errors.push(`Missing required release file: ${relative}`); + } + } + + const findings = findSensitiveReleaseEntries(root); + for (const finding of findings) { + errors.push(`Unsafe release content (${finding.type}): ${finding.path}`); + } + + try { + const runtimePackage = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + const actualDependencies = runtimePackage.dependencies || {}; + for (const [name, version] of Object.entries(expectedDependencies || {})) { + if (actualDependencies[name] !== version) { + errors.push( + `Runtime dependency mismatch for ${name}: expected ${version}, got ${actualDependencies[name] || 'missing'}` + ); + } + } + if (runtimePackage.scripts && Object.keys(runtimePackage.scripts).length > 0) { + errors.push('Runtime package.json must not contain lifecycle scripts'); + } + } catch (error) { + errors.push(`Invalid runtime package.json: ${error.message}`); + } + + return { ok: errors.length === 0, errors, findings }; +} + +module.exports = { + FORBIDDEN_DIRECTORY_NAMES, + FORBIDDEN_FILE_NAMES, + assertSafeReleasePath, + collectReleaseEntries, + findSensitiveReleaseEntries, + isPathInside, + normalizeRelativePath, + requiredReleasePaths, + validateReleaseDirectory, +}; diff --git a/lib/test-sharding.js b/lib/test-sharding.js new file mode 100644 index 00000000..ff1df36d --- /dev/null +++ b/lib/test-sharding.js @@ -0,0 +1,28 @@ +function readSafeInteger(env, name, fallback) { + const raw = env[name]; + if (raw === undefined || raw === '') return fallback; + const value = Number(raw); + if (!Number.isSafeInteger(value)) { + throw new TypeError(`${name} must be a safe integer`); + } + return value; +} + +function selectTestShard(tests, env = {}) { + if (!Array.isArray(tests)) throw new TypeError('tests must be an array'); + const count = readSafeInteger(env, 'TEST_SHARD_COUNT', 1); + const index = readSafeInteger(env, 'TEST_SHARD_INDEX', 0); + if (count < 1 || index < 0 || index >= count) { + throw new RangeError('expected 0 <= TEST_SHARD_INDEX < TEST_SHARD_COUNT'); + } + if (tests.length > 0 && count > tests.length) { + throw new RangeError('TEST_SHARD_COUNT cannot exceed the number of tests'); + } + return { + count, + index, + tests: tests.filter((_, testIndex) => testIndex % count === index), + }; +} + +module.exports = { selectTestShard }; diff --git a/openai-proxy.js b/openai-proxy.js index 8d37b2ad..dd743311 100644 --- a/openai-proxy.js +++ b/openai-proxy.js @@ -8,10 +8,9 @@ const http = require('http'); const fs = require('fs'); const path = require('path'); const os = require('os'); -const url = require('url'); -const net = require('net'); const util = require('util'); const crypto = require('crypto'); +const { parseBoundedInteger } = require('./lib/proxy/management-utils'); const { createLogger, @@ -19,6 +18,7 @@ const { newRequestId, getReqId, installReqIdMiddleware, + sanitizeLogArgs, } = require('./lib/proxy/logger'); const { createMetrics, collectDefaultProcessMetrics } = require('./lib/proxy/metrics'); const log = createLogger('proxy'); @@ -49,7 +49,7 @@ try { function shouldVerifyUpstreamTls() { if (process.env.PROXY_ALLOW_UNTRUSTED_UPSTREAM === '1') return false; try { - const cfg = loadConfig(); + const cfg = require('./lib/proxy/config').loadConfig(); return cfg.appSettings && cfg.appSettings.verifyUpstreamTls !== false; } catch (e) { return true; @@ -76,335 +76,19 @@ const globalHttpAgent = new http.Agent({ https.globalAgent = globalHttpsAgent; http.globalAgent = globalHttpAgent; -let detectedProxyUrl = null; -let detectedProxyType = 'http'; // 'http' 或 'socks5' -let lastDetectTime = 0; -let proxyDetectCached = false; - -function resetLocalProxyDetectionCache() { - detectedProxyUrl = null; - detectedProxyType = 'http'; - lastDetectTime = 0; - proxyDetectCached = false; -} - -function detectLocalProxy(callback) { - try { - const { loadConfig } = require('./lib/proxy/config'); - const cfg = loadConfig(); - if (cfg && cfg.appSettings && cfg.appSettings.disableLocalProxy) { - if (callback) callback(null, 'http'); - return; - } - } catch (e) {} - - if (proxyDetectCached && Date.now() - lastDetectTime < 15000) { - if (callback) callback(detectedProxyUrl, detectedProxyType); - return; - } - - const envProxy = - process.env.HTTPS_PROXY || - process.env.HTTP_PROXY || - process.env.https_proxy || - process.env.http_proxy; - if (envProxy) { - detectedProxyUrl = envProxy; - detectedProxyType = envProxy.startsWith('socks') ? 'socks5' : 'http'; - lastDetectTime = Date.now(); - proxyDetectCached = true; - if (callback) callback(detectedProxyUrl, detectedProxyType); - return; - } - - const ports = [7890, 10809, 7893, 10808]; - let finished = 0; - let found = false; - - for (const port of ports) { - const socket = new net.Socket(); - socket.setTimeout(250); // 250ms timeout for robustness - - socket.on('connect', () => { - if (found) { - socket.destroy(); - return; - } - - socket.write(Buffer.from([0x05, 0x01, 0x00])); - - const onProxyFound = (type) => { - if (found) return; - found = true; - detectedProxyType = type; - detectedProxyUrl = `${type}://127.0.0.1:${port}`; - console.log( - `[proxy] Auto-detected local ${type} proxy on port ${port}: ${detectedProxyUrl}` - ); - lastDetectTime = Date.now(); - proxyDetectCached = true; - socket.destroy(); - if (callback) callback(detectedProxyUrl, detectedProxyType); - }; - - socket.once('data', (chunk) => { - if (chunk.length >= 2 && chunk[0] === 0x05) { - onProxyFound('socks5'); - } else { - onProxyFound('http'); - } - }); - - socket.on('end', () => onProxyFound('http')); - socket.on('close', () => { - if (!found) onProxyFound('http'); - }); - socket.on('error', () => { - if (!found) onProxyFound('http'); - }); - }); - - const onFail = () => { - if (!found && !socket.destroyed) { - socket.destroy(); - checkFinished(); - } - }; - - socket.on('timeout', onFail); - socket.on('error', onFail); - - socket.connect(port, '127.0.0.1'); - } - - function checkFinished() { - finished++; - if (finished === ports.length && !found) { - detectedProxyUrl = null; - lastDetectTime = Date.now(); - proxyDetectCached = true; - if (callback) callback(null, 'http'); - } - } -} - -// 供连接测试使用的通用请求函数,支持多协议代理检测与无缝自愈隧道 -function requestWithProxy(targetUrl, options, callback) { - const EventEmitter = require('events'); - const { SocksProxyAgent } = require('socks-proxy-agent'); - const emitter = new EventEmitter(); - const requestTimeout = Number(options && options.timeout) || 0; - - function attachRequestTimeout(req) { - if (requestTimeout > 0 && req && typeof req.setTimeout === 'function') { - req.setTimeout(requestTimeout, () => { - req.destroy(new Error(`Request timeout (${requestTimeout}ms)`)); - }); - } - return req; - } - - emitter.write = function (chunk) { - if (emitter.activeReq) emitter.activeReq.write(chunk); - else emitter.pendingBody = (emitter.pendingBody || '') + chunk; - }; - emitter.end = function () { - if (emitter.activeReq) emitter.activeReq.end(); - else emitter.ended = true; - }; - emitter.destroy = function (err) { - if (emitter.activeReq && typeof emitter.activeReq.destroy === 'function') { - emitter.activeReq.destroy(err); - } - }; - - detectLocalProxy((proxyUrl, proxyType) => { - const target = url.parse(targetUrl); - let fallbackTriggered = false; - - function executeDirect() { - if (fallbackTriggered) return; - fallbackTriggered = true; - - const transport = target.protocol === 'https:' ? https : http; - const req = attachRequestTimeout(transport.request(targetUrl, options, callback)); - emitter.activeReq = req; - - req.on('error', (e) => { - emitter.emit('error', e); - }); - - if (emitter.pendingBody) { - req.write(emitter.pendingBody); - } - req.end(); - } - - if (proxyUrl) { - const proxy = url.parse(proxyUrl); - - if (target.protocol === 'https:') { - if (proxyType === 'socks5') { - // SOCKS5 tunnel via socks-proxy-agent (handles handshake + CONNECT) - let socksAgent; - try { - socksAgent = new SocksProxyAgent(proxyUrl, { - timeout: requestTimeout || 5000, - }); - } catch (agentErr) { - console.warn( - `[proxy] SOCKS5 agent init failed (${agentErr.message}), falling back to direct connection...` - ); - executeDirect(); - return; - } - const req = attachRequestTimeout( - https.request( - { - agent: socksAgent, - hostname: target.hostname, - port: target.port || 443, - path: target.path, - method: options.method || 'GET', - headers: { ...(options.headers || {}), host: target.hostname }, - }, - callback - ) - ); - emitter.activeReq = req; - req.on('error', (e) => { - if (!fallbackTriggered) { - console.warn( - `[proxy] SOCKS5 via ${proxyUrl} failed (${e.message}), falling back to direct connection...` - ); - fallbackTriggered = true; - executeDirect(); - } else { - emitter.emit('error', e); - } - }); - if (emitter.pendingBody) { - req.write(emitter.pendingBody); - } - req.end(); - } else { - // HTTP CONNECT tunnel - const connectReq = attachRequestTimeout( - http.request({ - hostname: proxy.hostname, - port: proxy.port, - method: 'CONNECT', - path: target.hostname + ':' + (target.port || 443), - headers: proxy.auth - ? { - 'Proxy-Authorization': 'Basic ' + Buffer.from(proxy.auth).toString('base64'), - } - : {}, - }) - ); - emitter.activeReq = connectReq; - - connectReq.on('connect', (res, socket, head) => { - socket.on('error', (e) => { - emitter.emit('error', e); - }); - - if (fallbackTriggered) { - socket.destroy(); - return; - } - fallbackTriggered = true; - - if (res.statusCode !== 200) { - emitter.emit('error', new Error('Tunnel connection failed: ' + res.statusCode)); - socket.destroy(); - return; - } - - const tls = require('tls'); - const tlsSocket = tls.connect( - { - socket: socket, - servername: target.hostname, - rejectUnauthorized: shouldVerifyUpstreamTls(), - }, - () => { - const headers = { ...options.headers, host: target.hostname }; - const req = attachRequestTimeout( - http.request( - { - createConnection: () => tlsSocket, - method: options.method || 'GET', - path: target.path, - headers: headers, - }, - callback - ) - ); - - emitter.activeReq = req; - req.on('error', (e) => emitter.emit('error', e)); - - if (emitter.pendingBody) { - req.write(emitter.pendingBody); - } - req.end(); - } - ); - - tlsSocket.on('error', (e) => { - emitter.emit('error', e); - }); - }); - - connectReq.on('error', (e) => { - if (!fallbackTriggered) { - console.warn( - `[proxy] HTTP proxy via ${proxyUrl} failed (${e.message}), falling back to direct connection...` - ); - executeDirect(); - } - }); - - connectReq.end(); - } - } else { - // HTTP target through HTTP proxy - const headers = { ...options.headers, host: target.hostname }; - if (proxy.auth) { - headers['Proxy-Authorization'] = 'Basic ' + Buffer.from(proxy.auth).toString('base64'); - } - const req = attachRequestTimeout( - http.request( - { - hostname: proxy.hostname, - port: proxy.port, - path: targetUrl, - method: options.method || 'GET', - headers: headers, - }, - callback - ) - ); - - emitter.activeReq = req; - req.on('error', (e) => emitter.emit('error', e)); - - if (emitter.pendingBody) { - req.write(emitter.pendingBody); - } - req.end(); - } - } else { - executeDirect(); - } - }); - - return emitter; -} - const { createAuthResolver } = require('./lib/proxy/auth-resolver'); +const { createClaudeSelfTestTargetResolver } = require('./lib/proxy/claude-self-test-target'); +const { createCoreRequestHandler } = require('./lib/proxy/core-request-handler'); const { createManagementHandlers } = require('./lib/proxy/handlers/management'); +const { createProxyTransport } = require('./lib/proxy/proxy-transport'); +const { createRequestObservability } = require('./lib/proxy/request-observability'); +const { createRuntimeConfigController } = require('./lib/proxy/runtime-config-controller'); +const { + createRuntimeSupport, + isTauriManagementOrigin, + maskManagementToken, +} = require('./lib/proxy/runtime-support'); +const { startProxyServers } = require('./lib/proxy/server-lifecycle'); const { DATA_DIR, @@ -424,115 +108,21 @@ const { migrateLegacyRuntimeFiles(); -function readExistingManagementToken(managementPort) { - if (MANAGEMENT_TOKEN) return MANAGEMENT_TOKEN; - const candidates = []; - const portNumber = Number(managementPort); - if (Number.isFinite(portNumber) && portNumber > 0) { - candidates.push(path.join(DATA_DIR, `.mgmt-token-${portNumber}`)); - } - candidates.push(path.join(DATA_DIR, '.mgmt-token')); - for (const tokenFile of candidates) { - try { - if (!fs.existsSync(tokenFile)) continue; - const token = fs.readFileSync(tokenFile, 'utf8').trim(); - if (/^[0-9a-f]{32}$/i.test(token)) return token; - } catch (err) {} - } - return ''; -} +const { requestWithProxy, resetLocalProxyDetectionCache } = createProxyTransport({ + loadConfig, + shouldVerifyUpstreamTls, +}); +const runtimeSupport = createRuntimeSupport({ + dataDir: DATA_DIR, + initialToken: MANAGEMENT_TOKEN, + logger: log, +}); function writeManagementTokenFile(managementPort) { - try { - MANAGEMENT_TOKEN = - readExistingManagementToken(managementPort) || crypto.randomBytes(16).toString('hex'); - fs.mkdirSync(DATA_DIR, { recursive: true }); - const tokenFiles = [path.join(DATA_DIR, '.mgmt-token')]; - const portNumber = Number(managementPort); - if (Number.isFinite(portNumber) && portNumber > 0) { - tokenFiles.unshift(path.join(DATA_DIR, `.mgmt-token-${portNumber}`)); - } - for (const tokenFile of tokenFiles) { - fs.writeFileSync(tokenFile, MANAGEMENT_TOKEN, { encoding: 'utf8', mode: 0o600 }); - } - } catch (err) { - log.warn('[mgmt] Failed to write management token file:', err.message); - } -} - -function maskManagementToken(token) { - const value = String(token || ''); - if (value.length <= 12) return '[redacted]'; - return value.slice(0, 6) + '...' + value.slice(-4); + MANAGEMENT_TOKEN = runtimeSupport.writeManagementTokenFile(managementPort); + return MANAGEMENT_TOKEN; } - -function isTauriManagementOrigin(req) { - const origin = req && req.headers && req.headers.origin ? String(req.headers.origin) : ''; - return ( - origin === 'tauri://localhost' || - origin === 'http://tauri.localhost' || - origin === 'https://tauri.localhost' - ); -} - -function setupFileConsoleTee() { - if (process.env.PROXY_DISABLE_FILE_LOG === '1') return; - try { - fs.mkdirSync(DATA_DIR, { recursive: true }); - const maxLogBytes = Math.max( - 256 * 1024, - Number(process.env.PROXY_LOG_MAX_BYTES || 5 * 1024 * 1024) - ); - const rotateIfOversized = (filePath) => { - try { - if (!fs.existsSync(filePath)) return; - const stat = fs.statSync(filePath); - if (stat.size <= maxLogBytes) return; - const rotated = filePath + '.1'; - try { - fs.unlinkSync(rotated); - } catch (_) {} - try { - fs.renameSync(filePath, rotated); - } catch (_) { - fs.writeFileSync(filePath, '', 'utf8'); - } - } catch (_) {} - }; - const logPath = path.join(DATA_DIR, 'proxy.log'); - const errPath = path.join(DATA_DIR, 'proxy.err.log'); - rotateIfOversized(logPath); - rotateIfOversized(errPath); - const out = fs.createWriteStream(logPath, { flags: 'a', encoding: 'utf8' }); - const err = fs.createWriteStream(errPath, { flags: 'a', encoding: 'utf8' }); - const originalLog = console.log.bind(console); - const originalWarn = console.warn.bind(console); - const originalError = console.error.bind(console); - const writeLine = (stream, args) => { - try { - stream.write(util.format(...args) + '\n'); - } catch (_) {} - }; - console.log = (...args) => { - originalLog(...args); - writeLine(out, args); - }; - console.warn = (...args) => { - originalWarn(...args); - writeLine(err, args); - }; - console.error = (...args) => { - originalError(...args); - writeLine(err, args); - }; - } catch (e) { - try { - log.error('[log] Failed to initialize file logging:', e.message); - } catch (_) {} - } -} - -setupFileConsoleTee(); +runtimeSupport.setupFileConsoleTee(); const { stableHash, @@ -669,13 +259,7 @@ const { profileMatchesEntryKey, stripEntryAuthHeaders, } = require('./lib/proxy/entry-auth'); -const { - retryDelayMs, - shouldRetryUpstreamStatus, - shouldFailoverOnStatus, - shouldRetryAttempt, - shouldFailoverOnError, -} = require('./lib/proxy/upstream-retry'); +const { resolveUpstreamRecovery } = require('./lib/proxy/upstream-retry'); const { redactHeaders, headersForReplay, @@ -798,7 +382,7 @@ const { startNativeDeepProgress, } = require('./lib/proxy/trae-debug-center'); -let CONFIG = loadConfig(); +let CONFIG = loadConfig({ throwOnError: true }); let PROFILES = getProfiles(CONFIG); let ACTIVE_PROFILE = getActiveProfile(CONFIG); let LOCAL_SERVER = getLocalServer(CONFIG); @@ -821,66 +405,17 @@ const makeLocalClaudeSelfTestRequest = createHttpClient({ }); const mcpHub = createMcpHub({ getConfig: () => CONFIG }); -function getClaudeDesktopSelfTestRoute() { - const routes = getClaudeDesktopRoutes(CONFIG); - if (!Array.isArray(routes) || routes.length === 0) return null; - const traeRoute = routes.find((route) => { - const profile = PROFILES.find((item) => item && item.id === route.profileId) || {}; - return isTraeOfficialProfile(profile) || isTraeOfficialTarget(profile.targetUrl); - }); - return traeRoute || routes[0]; -} - -function appendClaudeMessagesPath(baseUrl) { - const normalized = String(baseUrl || '').replace(/\/+$/, ''); - if (!normalized) return ''; - return /\/v1$/i.test(normalized) ? normalized + '/messages' : normalized + '/v1/messages'; -} - -function getClaudeDesktopSelfTestTarget() { - const route = getClaudeDesktopSelfTestRoute(); - if (!route || !route.from) return null; - - const gateway = getClaudeDesktopGatewayConfig(); - let baseUrl = ''; - let token = ''; - if (gateway && gateway.isLocalProxy && gateway.baseUrl) { - baseUrl = gateway.baseUrl; - token = gateway.token || ''; - } else { - baseUrl = claudeDesktopBaseUrl(CONFIG) + CLAUDE_DESKTOP_PROXY_PREFIX; - token = - CONFIG && CONFIG.claudeDesktop && typeof CONFIG.claudeDesktop.token === 'string' - ? CONFIG.claudeDesktop.token - : ''; - } - - const endpoint = appendClaudeMessagesPath(baseUrl); - if (!endpoint) return null; - const headers = { - 'Content-Type': 'application/json', - 'anthropic-version': '2023-06-01', - }; - if (token) headers.Authorization = 'Bearer ' + token; - const profile = PROFILES.find((item) => item && item.id === route.profileId) || {}; - return { - source: - gateway && gateway.isLocalProxy - ? 'claude-desktop-gateway' - : 'configured-claude-desktop-gateway', - endpoint, - headers, - model: route.from, - expectedStyle: getClaudeDesktopTraeProcessStyle(CONFIG), - route: { - from: route.from, - profileId: route.profileId, - profileProtocol: profile.protocol || '', - targetUrl: profile.targetUrl || '', - isTraeOfficial: isTraeOfficialProfile(profile) || isTraeOfficialTarget(profile.targetUrl), - }, - }; -} +const getClaudeDesktopSelfTestTarget = createClaudeSelfTestTargetResolver({ + claudeDesktopBaseUrl, + claudeDesktopProxyPrefix: CLAUDE_DESKTOP_PROXY_PREFIX, + getClaudeDesktopGatewayConfig, + getClaudeDesktopRoutes, + getClaudeDesktopTraeProcessStyle, + getConfig: () => CONFIG, + getProfiles: () => PROFILES, + isTraeOfficialProfile, + isTraeOfficialTarget, +}); const { refreshGoogleAccessToken, getFreshAntigravityOfficialAccessToken, @@ -972,6 +507,87 @@ const { resolveOfficialCredentials, resolveConfiguredProfile } = createAuthResol PROFILES: () => PROFILES, // Use a getter if PROFILES is re-assigned }); +const { + AUDIT_MAX_CAPTURE_BYTES, + AUDIT_MAX_TEXT_CHARS, + MAX_CONVERSION_RESPONSE_BYTES, + MAX_REQUEST_BODY_BYTES, + appendAuditText, + auditClaudeSseToolCalls, + auditToolCall, + compactDiagnosticsValue, + contentLengthForDiagnostics, + diagnosticsMetaFromResponse, + extractDiagnosticsSsePayload, + filterClaudeCompatibleBlocks, + flushJsonlWrites, + getDiagnosticsConfig, + inputKeys, + installLocalManagementRequestEndLogger, + isClaudeThinkingBlock, + logModelMapping, + logProxyEvent, + logRequestEnd, + normalizeClaudeContent, + populateAuditDataFromPrepared, + redactDiagnosticsValue, + sampleDiagnostics, + sampleGeminiToolEvent, + sampleOutboundSse, + sampleRequestToolResults, + sampleResponseChunk, + shouldCaptureDiagnostics, + summarizeClaudeToolHistory, + truncateDiagnosticsText, + writeJsonlBounded, +} = createRequestObservability({ + appendAuditTextBounded, + captureRequestBody, + createClaudeFilters, + createDiagnostics, + estimateTokens, + getReqId, + isClaudeProfile, + isGeminiProfile, + isQuietLocalPollRequest, + logRouteModelMapping, + metricsInstance, + parseBoundedInteger, + populateAuditDataFromPreparedWithDeps, + sanitizeGeminiToolCall, + getConfig: () => CONFIG, + protocolOptions: PROTOCOL_OPTIONS, +}); + +const { reloadProxyConfig } = createRuntimeConfigController({ + configFile: CONFIG_FILE, + createHttpsAgent, + defaultProtocolOptions: DEFAULT_CONFIG.protocolOptions, + getActiveProfile, + getClaudeDesktopRoutes, + getLocalServer, + getModelRoutes, + getProfiles, + loadConfig, + log, + metricsInstance, + protocolOptions: PROTOCOL_OPTIONS, + rebuildProxyRouteIndex, + resetLocalProxyDetectionCache, + setHttpsAgent: (agent) => { + globalHttpsAgent = agent; + https.globalAgent = agent; + }, + setRuntimeState: (state) => { + CONFIG = state.config; + PROFILES = state.profiles; + ACTIVE_PROFILE = state.activeProfile; + LOCAL_SERVER = state.localServer; + MODEL_ROUTES = state.modelRoutes; + CLAUDE_DESKTOP_ROUTES = state.claudeDesktopRoutes; + }, +}); + const managementHandlers = createManagementHandlers({ CONFIG: () => CONFIG, PROFILES: () => PROFILES, @@ -1033,99 +649,6 @@ const managementHandlers = createManagementHandlers({ globalHttpsAgent, }); -// 配置文件热重载:debounce + lock + 重试 -let _configWatcher = null; -function reloadProxyConfig() { - // 兼容旧 API: 立刻调度一次重载 - if (_configWatcher && typeof _configWatcher.scheduleNow === 'function') { - _configWatcher.scheduleNow(); - } else { - // 没有装上 watcher 时退化为旧实现 - try { - const nextConfig = loadConfig(); - CONFIG = nextConfig; - PROFILES = getProfiles(nextConfig); - ACTIVE_PROFILE = getActiveProfile(nextConfig); - LOCAL_SERVER = getLocalServer(nextConfig); - MODEL_ROUTES = getModelRoutes(nextConfig); - CLAUDE_DESKTOP_ROUTES = getClaudeDesktopRoutes(nextConfig); - - const nextOptions = { - ...DEFAULT_CONFIG.protocolOptions, - ...(nextConfig.protocolOptions || {}), - }; - for (const key of Object.keys(PROTOCOL_OPTIONS)) { - delete PROTOCOL_OPTIONS[key]; - } - Object.assign(PROTOCOL_OPTIONS, nextOptions); - - const newAgent = createHttpsAgent(); - globalHttpsAgent = newAgent; - https.globalAgent = newAgent; - - log.info('[config] Configuration reloaded (legacy). Active profile:', ACTIVE_PROFILE.id); - } catch (err) { - log.error('[config] Failed to reload configuration:', err.message); - } - } -} - -try { - const { debounceReload } = require('./lib/proxy/config-watcher'); - _configWatcher = debounceReload( - CONFIG_FILE, - (nextConfig) => { - try { - CONFIG = nextConfig; - PROFILES = getProfiles(nextConfig); - ACTIVE_PROFILE = getActiveProfile(nextConfig); - LOCAL_SERVER = getLocalServer(nextConfig); - MODEL_ROUTES = getModelRoutes(nextConfig); - CLAUDE_DESKTOP_ROUTES = getClaudeDesktopRoutes(nextConfig); - - const nextOptions = { - ...DEFAULT_CONFIG.protocolOptions, - ...(nextConfig.protocolOptions || {}), - }; - for (const key of Object.keys(PROTOCOL_OPTIONS)) { - delete PROTOCOL_OPTIONS[key]; - } - Object.assign(PROTOCOL_OPTIONS, nextOptions); - - // Recreate HTTPS agent when TLS verification setting changes - const newAgent = createHttpsAgent(); - globalHttpsAgent = newAgent; - https.globalAgent = newAgent; - resetLocalProxyDetectionCache(); - - log.info( - '[config] Configuration hot-reloaded successfully. Active profile:', - ACTIVE_PROFILE.id - ); - // 指标埋点 - try { - metricsInstance.counter('proxy_config_reloads_total').inc(); - metricsInstance.gauge('proxy_active_profile_id').set(1, { id: ACTIVE_PROFILE.id }); - if (rebuildProxyRouteIndex(MODEL_ROUTES)) { - metricsInstance.counter('proxy_route_cache_total').inc(1, { event: 'rebuild' }); - } - } catch (e) { - /* never break on metrics */ - } - } catch (err) { - log.error('[config] Failed to apply hot-reload configuration:', err.message); - throw err; - } - }, - { - onError: (err) => log.warn('[config] Hot-reload error:', err && err.message), - loadConfigFn: () => loadConfig(), - } - ); -} catch (err) { - log.warn('[config] Failed to watch config file:', err.message); -} - function fallbackId(prefix, value) { return fallbackIdWithOptions(prefix, value, PROTOCOL_OPTIONS); } @@ -1152,123 +675,6 @@ function prepareRequestBufferWithResolver(buffer, encoding, resolver) { return prepareRequestBufferForRoute(buffer, encoding, ACTIVE_PROFILE, resolver || resolveRoute); } -function logModelMapping(kind, method, upstreamPath, prepared, requestBuffer) { - const estimatedContextTokens = estimateTokens( - captureRequestBody(prepared.buffer || requestBuffer).body - ); - return logRouteModelMapping( - kind, - method, - upstreamPath, - { ...prepared, estimatedContextTokens }, - logProxyEvent - ); -} - -const { normalizeClaudeContent, isClaudeThinkingBlock, filterClaudeCompatibleBlocks } = - createClaudeFilters({ - protocolOptions: PROTOCOL_OPTIONS, - makeSafeClaudeToolUseContentBlock: () => ({ type: 'text', text: '' }), - }); - -const { - getDiagnosticsConfig, - shouldCaptureDiagnostics, - redactDiagnosticsValue, - truncateDiagnosticsText, - compactDiagnosticsValue, - writeJsonlBounded, - flushJsonlWrites, - diagnosticsMetaFromResponse, - sampleDiagnostics, - extractDiagnosticsSsePayload, - sampleOutboundSse, - inputKeys, - contentLengthForDiagnostics, - summarizeClaudeToolHistory, - sampleRequestToolResults, - sampleGeminiToolEvent, - logProxyEvent: writeProxyEvent, - auditToolCall, - auditClaudeSseToolCalls, - sampleResponseChunk, -} = createDiagnostics({ getConfig: () => CONFIG, normalizeClaudeContent, sanitizeGeminiToolCall }); - -function shouldLogProxyEvent(kind, payload) { - if (!payload) return true; - if (kind !== 'request_start' && kind !== 'request_end') return true; - const statusCode = Number(payload.statusCode || 0); - if (statusCode >= 400) return true; - return !isQuietLocalPollRequest(payload.method, payload.path); -} - -function logProxyEvent(kind, payload) { - if (!shouldLogProxyEvent(kind, payload)) return false; - writeProxyEvent(kind, payload); - return true; -} - -function logRequestEnd(method, path, startTime, statusCode, extra) { - const elapsed = Date.now() - startTime; - logProxyEvent('request_end', { - reqId: getReqId(), - method, - path, - elapsed, - statusCode, - ...(extra || {}), - }); - // 指标埋点:观察直方图 + 错误计数 - try { - metricsInstance.histogram('proxy_request_duration_seconds').observe(elapsed / 1000); - if (statusCode && statusCode >= 500) { - metricsInstance - .counter('proxy_errors_total') - .inc(1, { type: (extra && extra.type) || 'unknown' }); - } - } catch (e) { - /* never break on metrics */ - } -} - -function installLocalManagementRequestEndLogger(clientRes, method, path, startTime) { - if (!clientRes || clientRes.__localManagementRequestEndLogger) return; - clientRes.__localManagementRequestEndLogger = true; - clientRes.once('finish', () => { - logRequestEnd(method, path, startTime, clientRes.statusCode || 200, { - type: 'local_management', - }); - }); -} - -const AUDIT_MAX_CAPTURE_BYTES = Math.max( - 16 * 1024, - Number(process.env.PROXY_AUDIT_MAX_CAPTURE_BYTES || 256 * 1024) -); -const AUDIT_MAX_TEXT_CHARS = Math.max( - 1024, - Number(process.env.PROXY_AUDIT_MAX_TEXT_CHARS || 120000) -); -const MAX_REQUEST_BODY_BYTES = Math.max( - 1024 * 1024, - Number(process.env.PROXY_MAX_REQUEST_BODY_BYTES || 32 * 1024 * 1024) -); -const MAX_CONVERSION_RESPONSE_BYTES = Math.max( - 1024 * 1024, - Number(process.env.PROXY_MAX_CONVERSION_RESPONSE_BYTES || 16 * 1024 * 1024) -); - -function appendAuditText(current, addition) { - return appendAuditTextBounded(current, addition, AUDIT_MAX_TEXT_CHARS); -} - -function populateAuditDataFromPrepared(clientRes, prepared, requestBuffer) { - return populateAuditDataFromPreparedWithDeps(clientRes, prepared, requestBuffer, { - isGeminiProfile, - isClaudeProfile, - }); -} - // 全局异常处理,避免进程意外崩溃 const exceptionHandlers = require('./lib/proxy/exception-handlers'); exceptionHandlers.install({ @@ -1520,1060 +926,139 @@ function handleRequest(clientReq, clientRes, entry) { return withRequest({ reqId }, () => handleRequestInner(clientReq, clientRes, entry)); } -function handleRequestInner(clientReq, clientRes, entry) { - const startTime = Date.now(); - const clientIp = getClientIp(clientReq); - const urlPath = clientReq.url; - const method = clientReq.method; - const desktopRequest = isClaudeDesktopRequest(entry, urlPath); - const effectiveUrlPath = desktopRequest ? stripClaudeDesktopPrefix(urlPath) : urlPath; - const effectiveUrlPathname = effectiveUrlPath.split('?')[0]; - const localManagementRequest = isLocalManagementPath(effectiveUrlPathname); - // 指标埋点:每个非本地管理请求递增 - const isApiRequest = isConversationRequestPath(method, effectiveUrlPath); - if (!localManagementRequest) { - metricsInstance.counter('proxy_requests_total').inc(); - } - const auditEnabled = - isApiRequest && - process.env.PROXY_DISABLE_CONVERSATION_AUDIT !== '1' && - !(CONFIG.appSettings && CONFIG.appSettings.conversationAuditEnabled === false); - - if (auditEnabled) { - const requestId = 'req-' + Date.now() + '-' + Math.random().toString(36).substring(2, 8); - clientRes.__auditId = requestId; - clientRes.__auditData = createConversationAuditData({ - id: requestId, - startTime: Date.now(), - method, - path: urlPath, - clientIp, - entryPort: entry && entry.listenPort, - entryProtocol: entryLabel(entry), - requestHeaders: clientReq.headers, - redactHeaders, - }); - - installConversationAuditResponseCapture(clientRes, { - maxCaptureBytes: AUDIT_MAX_CAPTURE_BYTES, - maxTextChars: AUDIT_MAX_TEXT_CHARS, - redactHeaders, - mergeUsage, - usageFromSsePayload, - extractUsageFromJsonPayload, - estimateUsageForRecord, - logUsage, - estimateCost, - appendAuditRecord, - config: CONFIG, - log, - }); - } - - const { cliSession } = resolveCliSession(clientReq.headers, CLI_SESSIONS); - if (cliSession) applyManagedAuthHeaders(clientReq.headers); - - const localPollRequest = isQuietLocalPollRequest(method, effectiveUrlPathname); - if (!localPollRequest) { - log.info('-> ' + method + ' ' + urlPath + ' [' + clientIp + '] entry=' + entryLabel(entry)); - logProxyEvent('request_start', { - reqId: clientReq.reqId, - method, - path: urlPath, - clientIp, - entryProtocol: entryLabel(entry), - entryPort: entry && entry.listenPort, - claudeDesktop: desktopRequest, - }); - } - - if (localManagementRequest && !installManagementCorsGuard(clientReq, clientRes)) { - logRequestEnd(method, urlPath, startTime, 403, { type: 'management_origin' }); - return; - } - - // CORS preflight - if (method === 'OPTIONS') { - clientRes.writeHead(204, { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': '*', - 'Access-Control-Max-Age': '86400', - }); - clientRes.end(); - return; - } - - // Local Admin APIs - if (localManagementRequest && !isPublicLocalManagementPath(effectiveUrlPathname)) { - installLocalManagementRequestEndLogger(clientRes, method, urlPath, startTime); - - const clientToken = - clientReq.headers['x-mgmt-token'] || - new URL(clientReq.url, 'http://localhost').searchParams.get('token'); - if (clientToken !== MANAGEMENT_TOKEN && !isTauriManagementOrigin(clientReq)) { - console.warn(`[mgmt] Unauthorized management request from ${clientIp}: ${method} ${urlPath}`); - clientRes.writeHead(401, { 'Content-Type': 'application/json' }); - clientRes.end(JSON.stringify({ error: 'Unauthorized management access' })); - logRequestEnd(method, urlPath, startTime, 401, { type: 'management_unauthorized' }); - return; - } - } - if ( - dispatchLocalManagementRoute({ - method, - pathname: effectiveUrlPathname, - req: clientReq, - res: clientRes, - handlers: managementHandlers, - }) - ) { - return; - } - - // Forward request - pass path as-is (SDK already includes /v1 from base_url) - // Normalize path: tools may or may not include /v1 prefix - if ( - (method === 'GET' || method === 'HEAD') && - (effectiveUrlPathname === '/favicon.ico' || effectiveUrlPathname === '/v1/favicon.ico') - ) { - clientRes.writeHead(204, { - 'Cache-Control': 'public, max-age=86400', - 'access-control-allow-origin': '*', - }); - clientRes.end(); - return; - } - - const clientPath = - effectiveUrlPath.indexOf('/v1') === 0 ? effectiveUrlPath : '/v1' + effectiveUrlPath; - const entryAuth = authorizeEntryRequest(clientReq, entry, CONFIG); - if (!entryAuth.ok) { - sendJsonError(clientRes, 401, { - error: { - type: 'authentication_error', - message: 'Invalid or missing proxy entry API key.', - }, - }); - logRequestEnd(method, urlPath, startTime, 401, { type: 'entry_auth' }); - return; - } - clientReq.entryAuth = entryAuth; - if (isEntryHealthPath(entry, method, clientPath)) { - sendEntryHealth(clientRes, entry, method, clientPath); - logRequestEnd(method, urlPath, startTime, 200, { - type: 'entry_health', - entryProtocol: entryLabel(entry), - }); - return; - } - if (!isAllowedEntryPath(entry, method, clientPath)) { - sendEntryPathError(clientRes, entry, method, clientPath); - logRequestEnd(method, urlPath, startTime, 404, { - type: 'entry_path_mismatch', - entryProtocol: entryLabel(entry), - }); - return; - } - - if (desktopRequest && !isAuthorizedClaudeDesktopRequest(CONFIG, clientReq.headers, decryptKey)) { - sendUnauthorizedClaudeDesktop(clientRes); - logRequestEnd(method, urlPath, startTime, 401, { type: 'claude_desktop_auth' }); - return; - } - - const modelRequest = parseModelsRequestPath(effectiveUrlPath); - if ((method === 'GET' || method === 'HEAD') && modelRequest) { - const entryRoutes = routesForEntry(entry, desktopRequest, { - modelRoutes: MODEL_ROUTES, - claudeDesktopRoutes: CLAUDE_DESKTOP_ROUTES, - }).filter( - (route) => - !entryAuth.enabled || profileMatchesEntryKey(entryAuth, { id: route && route.profileId }) - ); - const modelsResult = handleLocalModelsRequest( - clientReq, - clientRes, - modelRequest, - entryRoutes, - desktopRequest ? 'claude' : modelsFormatForEntry(entry), - desktopRequest ? 'claude' : entry && entry.protocol - ); - logRequestEnd(method, urlPath, startTime, modelsResult && modelsResult.status, { - type: 'local_models', - format: modelsResult && modelsResult.format, - modelCount: modelsResult && modelsResult.modelCount, - }); - return; - } - const fwdHeaders = { ...clientReq.headers }; - delete fwdHeaders['host']; - if (entryAuth.enabled) stripEntryAuthHeaders(fwdHeaders); - - collectLimitedRequestBody(clientReq, { - limit: MAX_REQUEST_BODY_BYTES, - onTooLarge({ bytes, limit }) { - logProxyEvent('request_too_large', { - method, - path: urlPath, - bytes, - limit, - }); - }, - onComplete({ tooLarge, bytes, buffer: requestBuffer }) { - if (tooLarge) { - sendRequestTooLarge(clientReq, clientRes, MAX_REQUEST_BODY_BYTES); - logRequestEnd(method, urlPath, startTime, 413, { - type: 'request_too_large', - bytes, - limit: MAX_REQUEST_BODY_BYTES, - }); - return; - } - if (clientRes.__auditData) { - const captured = captureRequestBody(requestBuffer, clientReq.headers['content-encoding']); - clientRes.__auditData.request = { - ...clientRes.__auditData.request, - body: captured.body, - truncated: captured.truncated, - originalLength: captured.originalLength, - }; - } - const desktop3pRequest = isClaudeDesktop3pRequest(clientReq.headers); - const shouldUseDesktopRoutes = desktopRequest || desktop3pRequest; - const initialPrepared = prepareRequestBufferWithResolver( - requestBuffer, - clientReq.headers['content-encoding'], - shouldUseDesktopRoutes ? resolveClaudeDesktopRoute : resolveRoute - ); - if (method === 'POST' && clientPath.indexOf('/v1/messages/count_tokens') === 0) { - populateAuditDataFromPrepared(clientRes, initialPrepared, requestBuffer); - handleCountTokens(clientReq, clientRes, requestBuffer, logRequestEnd, startTime); - return; - } - - function doForward(currentPrepared, attempt = 1) { - const MAX_RETRIES = 2; // Up to 3 total attempts - const requestProtocol = isGeminiProfile(currentPrepared.profile) - ? 'gemini' - : isClaudeProfile(currentPrepared.profile) - ? 'claude' - : 'openai'; - // 协议版本透传:Claude 走 anthropic-version header - const protocolVersion = resolveProfileProtocolVersion(currentPrepared.profile); - if (protocolVersion) { - // 通过 out-of-band 变量携带,稍后在 outgoingHeaders 合并 - currentPrepared._protocolVersion = protocolVersion; - } - - if (clientRes.__auditData) { - populateAuditDataFromPrepared(clientRes, currentPrepared, requestBuffer); - } - if ( - clientReq.entryAuth && - clientReq.entryAuth.enabled && - !profileMatchesEntryKey(clientReq.entryAuth, currentPrepared.profile) - ) { - sendJsonError(clientRes, 403, { - error: { - type: 'permission_denied', - message: 'Proxy entry API key is not allowed to use this route.', - }, - }); - logRequestEnd(method, urlPath, startTime, 403, { type: 'entry_auth_profile' }); - return; - } - - if ( - currentPrepared.authMode === 'official-login' && - currentPrepared.authProvider === 'trae' - ) { - if (method === 'POST' && isOpenAIChatCompletionsPath(clientPath)) { - handleTraeOfficialChatCompletion( - clientReq, - clientRes, - requestBuffer, - currentPrepared, - startTime, - urlPath - ); - return; - } - if (method === 'POST' && isOpenAIResponsesPath(clientPath)) { - handleTraeOfficialResponses( - clientReq, - clientRes, - requestBuffer, - currentPrepared, - startTime, - urlPath - ); - return; - } - if (method === 'POST' && isClaudeMessagesPath(clientPath)) { - handleTraeOfficialClaudeMessages( - clientReq, - clientRes, - requestBuffer, - currentPrepared, - startTime, - urlPath, - { - claudeProcessStyle: shouldUseDesktopRoutes - ? getClaudeDesktopTraeProcessStyle(CONFIG, { - claudeDesktop3p: desktop3pRequest, - }) - : '', - } - ); - return; - } - sendJsonError(clientRes, 501, { - error: { - type: 'not_implemented', - message: - 'Trae 官方登录桥接目前支持 OpenAI /v1/chat/completions、OpenAI /v1/responses 和 Claude /v1/messages。', - }, - }); - logRequestEnd(method, urlPath, startTime, 501, { - type: 'trae_official_unsupported_path', - }); - return; - } - - const target = parseTargetUrl(currentPrepared.profile.targetUrl); - const upstreamPath = target.path + clientPath.replace(/^\/v1/, ''); - const apiKey = - currentPrepared.authMode === 'official-login' - ? currentPrepared.profile.apiKey || null - : process.env.API_KEY || currentPrepared.profile.apiKey || null; - const isClaudeEntryRequest = forceClaudeEntry(entry) && isClaudeMessagesPath(clientPath); - const isChatEntryRequest = forceChatEntry(entry) && isOpenAIChatCompletionsPath(clientPath); - const isResponsesEntryRequest = - forceResponsesEntry(entry) && isOpenAIResponsesPath(clientPath); - - if ( - currentPrepared.authMode === 'official-login' && - currentPrepared.authProvider === 'google' && - isGeminiProfile(currentPrepared.profile) && - !(typeof apiKey === 'string' && apiKey.startsWith('ya29.')) - ) { - sendJsonError(clientRes, 401, { - error: { - type: 'authentication_error', - message: - 'Google official-login credentials are unavailable or expired. Please sign in again before using this route.', - }, - }); - logRequestEnd(method, urlPath, startTime, 401, { type: 'google_official_auth_missing' }); - return; - } - - const isResponsesUpstream = - currentPrepared.profile && - (currentPrepared.profile.api === 'openai-codex-responses' || - (currentPrepared.profile.targetUrl && - (currentPrepared.profile.targetUrl.includes('/backend-api/codex') || - currentPrepared.profile.targetUrl.includes('/backend-api')))); - - // 协议转换路由表:把 8 个 if 块(原 270 行)合并成数据驱动的调度。 - // 每条路由包含:匹配谓词、logModelMapping 标签、压缩请求体拒绝消息、目标 handler。 - // 第一个匹配的路由会处理并 return,后续路由不再评估(与原代码语义一致)。 - const conversionRoutes = [ - { - label: 'chat->responses', - match: - isResponsesUpstream && isOpenAIChatCompletionsPath(clientPath) && method === 'POST', - rejectMessage: - 'Chat to Responses conversion does not support compressed request bodies', - handler: handleOpenAIChatToOpenAIResponses, - }, - { - label: 'claude->responses', - match: isResponsesUpstream && isClaudeEntryRequest && method === 'POST', - rejectMessage: - 'Claude to Responses conversion does not support compressed request bodies', - handler: handleClaudeMessagesToOpenAIResponses, - }, - { - label: 'claude->openai', - match: - method === 'POST' && - (shouldConvertClaudeMessagesToOpenAI( - clientReq, - currentPrepared.profile, - clientPath - ) || - (isClaudeEntryRequest && isOpenAIProfile(currentPrepared.profile))), - rejectMessage: 'Claude to OpenAI conversion does not support compressed request bodies', - handler: handleClaudeMessagesToOpenAIChat, - }, - { - label: 'openai->gemini', - match: - isChatEntryRequest && isGeminiProfile(currentPrepared.profile) && method === 'POST', - rejectMessage: 'OpenAI to Gemini conversion does not support compressed request bodies', - handler: handleOpenAIChatToGeminiMessages, - }, - { - label: 'openai->claude', - match: - isClaudeProfile(currentPrepared.profile) && - method === 'POST' && - isOpenAIChatCompletionsPath(clientPath), - rejectMessage: 'OpenAI to Claude conversion does not support compressed request bodies', - handler: handleOpenAIChatToClaudeMessages, - }, - { - label: 'responses->claude', - match: - isResponsesEntryRequest && - method === 'POST' && - isClaudeProfile(currentPrepared.profile), - rejectMessage: 'OpenAI Responses conversion does not support compressed request bodies', - handler: handleOpenAIResponsesToClaudeMessages, - }, - { - label: 'responses->gemini', - match: - isResponsesEntryRequest && - method === 'POST' && - isGeminiProfile(currentPrepared.profile), - rejectMessage: 'OpenAI Responses conversion does not support compressed request bodies', - handler: handleOpenAIResponsesToGeminiMessages, - }, - { - label: 'responses->chat', - match: - isResponsesEntryRequest && - method === 'POST' && - isOpenAIProfile(currentPrepared.profile) && - shouldConvertResponsesToOpenAIChat(currentPrepared.profile), - rejectMessage: null, - handler: handleOpenAIResponsesToOpenAIChat, - }, - { - label: 'claude->gemini', - match: - isGeminiProfile(currentPrepared.profile) && - method === 'POST' && - isClaudeMessagesPath(clientPath), - rejectMessage: 'Gemini conversion does not support compressed request bodies', - handler: handleGeminiMessages, - }, - ]; - - function tryConversionRoute(route) { - if (!route.match) return false; - if (route.rejectMessage && clientReq.headers['content-encoding']) { - clientRes.writeHead(400, { - 'Content-Type': 'application/json; charset=utf-8', - 'access-control-allow-origin': '*', - }); - clientRes.end( - JSON.stringify({ - error: { type: 'invalid_request_error', message: route.rejectMessage }, - }) - ); - return true; - } - logModelMapping(route.label, method, upstreamPath, currentPrepared, requestBuffer); - route.handler( - clientReq, - clientRes, - currentPrepared, - target, - apiKey, - upstreamPath, - startTime - ); - return true; - } - - for (const route of conversionRoutes) { - if (tryConversionRoute(route)) return; - } - - if (method === 'POST' && isClaudeMessagesPath(clientPath)) - sampleRequestToolResults(currentPrepared, requestProtocol, upstreamPath); - - const outgoingHeaders = buildOutgoingHeaders(fwdHeaders, { - requestProtocol, - protocolVersion: currentPrepared._protocolVersion, - authMode: currentPrepared.authMode, - apiKey, - routed: currentPrepared.routed, - bodyBuffer: currentPrepared.buffer, - }); - if (currentPrepared.routed) { - logModelMapping('route', method, upstreamPath, currentPrepared, requestBuffer); - } - - if (clientRes.__auditData) { - clientRes.__auditData.upstream = { - method, - path: upstreamPath, - targetHost: target.hostname, - targetProtocol: target.protocol, - headers: redactHeaders(outgoingHeaders), - body: captureRequestBody(currentPrepared.buffer, outgoingHeaders['content-encoding']) - .body, - }; - } - - const transport = target.protocol === 'https:' ? https : http; - const proxyReq = transport.request( - { - hostname: process.env.TARGET || target.hostname, - port: parsePortOrDefault( - process.env.TARGET_PORT || target.port, - target.protocol === 'https:' ? 443 : 80 - ), - path: upstreamPath, - method: method, - headers: outgoingHeaders, - timeout: 45000, - agent: target.protocol === 'https:' ? globalHttpsAgent : globalHttpAgent, - }, - (proxyRes) => { - const code = proxyRes.statusCode; - - if (shouldRetryUpstreamStatus(code, attempt, MAX_RETRIES)) { - const delay = retryDelayMs(attempt); - console.warn( - `[Retry] Upstream returned ${code}, retrying in ${Math.round(delay)}ms... (Attempt ${attempt}/${MAX_RETRIES})` - ); - proxyRes.destroy(); - setTimeout(() => doForward(currentPrepared, attempt + 1), delay); - return; - } - - const backupProfile = shouldFailoverOnStatus( - code, - attempt, - currentPrepared, - getProfiles(loadConfig()) || [] - ); - if (backupProfile) { - console.warn( - `[Failover] Upstream returned status ${code}, falling back to backup profile: ${backupProfile.id}` - ); - const backupPrepared = { ...currentPrepared, profile: backupProfile }; - proxyRes.destroy(); - doForward(backupPrepared, 2); - return; - } - - const headers = { - ...proxyRes.headers, - 'access-control-allow-origin': '*', - 'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS', - 'access-control-allow-headers': '*', - }; - - const isStream = - (proxyRes.headers['content-type'] || '').indexOf('text/event-stream') >= 0; - const shouldSanitizeOpenAIStream = - code >= 200 && - code < 300 && - isStream && - isClaudeMessagesPath(clientPath) && - isOpenAIProfile(currentPrepared.profile); - const shouldProtectStream = - code >= 200 && - code < 300 && - isStream && - isClaudeMessagesPath(clientPath) && - !isOpenAIProfile(currentPrepared.profile); - if (shouldProtectStream || shouldSanitizeOpenAIStream) delete headers['content-length']; - clientRes.writeHead(code, headers); - const errorChunks = []; - let errorBytes = 0; - let logged = false; - - if (shouldSanitizeOpenAIStream) { - clientRes.__diagnostics = { - protocol: 'openai', - route: currentPrepared.model, - target: currentPrepared.rewriteTo, - upstreamPath, - profile: currentPrepared.profile && currentPrepared.profile.id, - }; - const sseState = { - buffer: '', - pendingClaudeTool: null, - mode: 'strip-empty-optionals', - }; - proxyRes.on('data', function (chunk) { - const chunkText = chunk.toString('utf8'); - if (!logged) logged = true; - sampleResponseChunk('openai-upstream', chunkText, { - route: currentPrepared.model, - target: currentPrepared.rewriteTo, - upstreamPath, - }); - processClaudeSseChunk(clientRes, chunkText, sseState); - }); - proxyRes.on('end', function () { - flushClaudeSseFilter(clientRes, sseState); - log.info( - 'ok ' + - method + - ' ' + - urlPath + - ' -> ' + - code + - ' (streaming sanitized, ' + - (Date.now() - startTime) + - 'ms)' - ); - clientRes.end(); - logRequestEnd(method, urlPath, startTime, code, { - type: 'openai_stream_sanitized', - }); - }); - return; - } - - if (shouldProtectStream) { - clientRes.__diagnostics = { - protocol: 'openai', - route: currentPrepared.model, - target: currentPrepared.rewriteTo, - upstreamPath, - profile: currentPrepared.profile && currentPrepared.profile.id, - }; - const sseState = { buffer: '', pendingClaudeTool: null }; - proxyRes.on('data', function (chunk) { - const chunkText = chunk.toString('utf8'); - if (!logged) logged = true; - sampleResponseChunk('openai-upstream', chunkText, { - route: currentPrepared.model, - target: currentPrepared.rewriteTo, - upstreamPath, - }); - processClaudeSseChunk(clientRes, chunkText, sseState); - }); - proxyRes.on('end', function () { - flushClaudeSseFilter(clientRes, sseState); - log.info( - 'ok ' + - method + - ' ' + - urlPath + - ' -> ' + - code + - ' (protected streaming, ' + - (Date.now() - startTime) + - 'ms)' - ); - clientRes.end(); - logRequestEnd(method, urlPath, startTime, code, { - type: 'openai_stream_protected', - }); - }); - return; - } - - proxyRes.on('data', function (chunk) { - if (!logged) { - const elapsed = Date.now() - startTime; - if (!isStream) - log.info( - 'ok ' + method + ' ' + urlPath + ' -> ' + code + ' (' + elapsed + 'ms)' - ); - logged = true; - } - - if (code >= 400 && !isStream && errorBytes < 4096) { - const remaining = 4096 - errorBytes; - const part = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk; - errorChunks.push(part); - errorBytes += part.length; - } - }); - - proxyRes.on('end', function () { - if (isStream) - log.info( - 'ok ' + - method + - ' ' + - urlPath + - ' -> ' + - code + - ' (streaming, ' + - (Date.now() - startTime) + - 'ms)' - ); - logRequestEnd(method, urlPath, startTime, code, { - type: isStream ? 'openai_stream' : 'openai', - }); - - if (code >= 400 && errorChunks.length > 0) { - const requestId = - proxyRes.headers['x-request-id'] || - proxyRes.headers['request-id'] || - proxyRes.headers['anthropic-request-id'] || - '-'; - const retryAfter = proxyRes.headers['retry-after'] || '-'; - const body = Buffer.concat(errorChunks) - .toString('utf8') - .replace(/\s+/g, ' ') - .trim(); - const requestedModel = currentPrepared.model || ''; - log.warn( - '!! upstream error ' + - method + - ' ' + - upstreamPath + - ' -> ' + - code + - ' model=' + - requestedModel + - ' request-id=' + - requestId + - ' retry-after=' + - retryAfter + - ' body=' + - body - ); - logProxyEvent('upstream_error', { - method, - path: upstreamPath, - code, - model: requestedModel, - body, - }); - } - }); - - proxyRes.pipe(clientRes); - } - ); - - proxyReq.on('timeout', function () { - if (shouldRetryAttempt(attempt, MAX_RETRIES)) { - const delay = retryDelayMs(attempt); - console.warn( - `[Retry] Upstream timeout, retrying in ${Math.round(delay)}ms... (Attempt ${attempt}/${MAX_RETRIES})` - ); - proxyReq.destroy(); - setTimeout(() => doForward(currentPrepared, attempt + 1), delay); - return; - } - proxyReq.destroy(new Error('Gateway Timeout (Upstream request timed out)')); - }); - - proxyReq.on('error', function (err) { - if (shouldRetryAttempt(attempt, MAX_RETRIES)) { - const delay = retryDelayMs(attempt); - console.warn( - `[Retry] Upstream error (${err.message}), retrying in ${Math.round(delay)}ms... (Attempt ${attempt}/${MAX_RETRIES})` - ); - proxyReq.destroy(); - setTimeout(() => doForward(currentPrepared, attempt + 1), delay); - return; - } - const backupProfile = shouldFailoverOnError( - attempt, - currentPrepared, - getProfiles(loadConfig()) || [] - ); - if (backupProfile) { - console.warn( - `[Failover] Upstream request failed (${err.message}), falling back to backup profile: ${backupProfile.id}` - ); - const backupPrepared = { ...currentPrepared, profile: backupProfile }; - doForward(backupPrepared, 2); - return; - } - - log.error('xx ' + method + ' ' + urlPath + ' ' + (err.code || err.message)); - logProxyEvent('proxy_error', { - method, - path: urlPath, - error: err.message, - code: err.code, - }); - if (!clientRes.headersSent) { - clientRes.writeHead(502, { - 'Content-Type': 'application/json; charset=utf-8', - 'access-control-allow-origin': '*', - }); - clientRes.end( - JSON.stringify({ - error: { message: 'Proxy error: ' + err.message, type: 'proxy_error' }, - }) - ); - } - }); - - proxyReq.end(currentPrepared.buffer); - } - - applyCliSessionModelOverride(initialPrepared, { - cliSession, - requestBuffer, - requestEncoding: clientReq.headers['content-encoding'], - resolveRoute, - modelRoutes: MODEL_ROUTES, - profiles: PROFILES, - activeProfile: ACTIVE_PROFILE, - }); - - resolveOfficialCredentials(initialPrepared, clientReq.headers) - .then(async function (prepared) { - if ( - isClaudeMessagesPath(clientPath) && - prepared.buffer && - !clientReq.headers['content-encoding'] - ) { - try { - const parsed = JSON.parse(prepared.buffer.toString('utf8')); - const augmented = await mcpHub.augmentRequestTools(parsed); - prepared.buffer = Buffer.from(JSON.stringify(augmented)); - } catch (e) {} - } - doForward(prepared, 1); - }) - .catch(function (err) { - log.error('[auth] Failed to resolve credentials:', err.message); - doForward(initialPrepared, 1); - }); - }, - onAborted() { - clientRes.destroy(); - }, - }); -} +const handleRequestInner = createCoreRequestHandler({ + AUDIT_MAX_CAPTURE_BYTES, + AUDIT_MAX_TEXT_CHARS, + MAX_REQUEST_BODY_BYTES, + appendAuditRecord, + applyCliSessionModelOverride, + applyManagedAuthHeaders, + authorizeEntryRequest, + buildOutgoingHeaders, + captureRequestBody, + collectLimitedRequestBody, + createConversationAuditData, + decryptKey, + dispatchLocalManagementRoute, + entryLabel, + estimateCost, + estimateUsageForRecord, + extractUsageFromJsonPayload, + flushClaudeSseFilter, + forceChatEntry, + forceClaudeEntry, + forceResponsesEntry, + getClaudeDesktopTraeProcessStyle, + getClientIp, + getProfiles, + handleClaudeMessagesToOpenAIChat, + handleClaudeMessagesToOpenAIResponses, + handleCountTokens, + handleGeminiMessages, + handleLocalModelsRequest, + handleOpenAIChatToClaudeMessages, + handleOpenAIChatToGeminiMessages, + handleOpenAIChatToOpenAIResponses, + handleOpenAIResponsesToClaudeMessages, + handleOpenAIResponsesToGeminiMessages, + handleOpenAIResponsesToOpenAIChat, + handleTraeOfficialChatCompletion, + handleTraeOfficialClaudeMessages, + handleTraeOfficialResponses, + installConversationAuditResponseCapture, + installLocalManagementRequestEndLogger, + installManagementCorsGuard, + isAllowedEntryPath, + isAuthorizedClaudeDesktopRequest, + isClaudeDesktop3pRequest, + isClaudeDesktopRequest, + isClaudeMessagesPath, + isClaudeProfile, + isConversationRequestPath, + isEntryHealthPath, + isGeminiProfile, + isLocalManagementPath, + isOpenAIChatCompletionsPath, + isOpenAIProfile, + isOpenAIResponsesPath, + isPublicLocalManagementPath, + isQuietLocalPollRequest, + isTauriManagementOrigin, + loadConfig, + log, + logModelMapping, + logProxyEvent, + logRequestEnd, + logUsage, + managementHandlers, + mcpHub, + mergeUsage, + metricsInstance, + modelsFormatForEntry, + parseModelsRequestPath, + parsePortOrDefault, + parseTargetUrl, + populateAuditDataFromPrepared, + prepareRequestBufferWithResolver, + processClaudeSseChunk, + profileMatchesEntryKey, + redactHeaders, + resolveClaudeDesktopRoute, + resolveCliSession, + resolveOfficialCredentials, + resolveProfileProtocolVersion, + resolveRoute, + resolveUpstreamRecovery, + routesForEntry, + sampleRequestToolResults, + sampleResponseChunk, + sendEntryHealth, + sendEntryPathError, + sendJsonError, + sendRequestTooLarge, + sendUnauthorizedClaudeDesktop, + shouldConvertClaudeMessagesToOpenAI, + shouldConvertResponsesToOpenAIChat, + stripClaudeDesktopPrefix, + stripEntryAuthHeaders, + usageFromSsePayload, + getRuntimeState: () => ({ + ACTIVE_PROFILE, + CLAUDE_DESKTOP_ROUTES, + CLI_SESSIONS, + CONFIG, + MANAGEMENT_TOKEN, + MODEL_ROUTES, + PROFILES, + globalHttpAgent, + globalHttpsAgent, + }), +}); /* ========== 启动 ========== */ if (require.main === module) { - const MGMT_PORT = parsePortOrDefault( - process.env.MGMT_PORT || CONFIG.localServer?.managementPort, - PORT <= 65435 ? PORT + 100 : 3543 - ); - - const checkExistingManagementServer = function (callback) { - let settled = false; - function finish(isProxy) { - if (settled) return; - settled = true; - callback(isProxy); - } - const req = http.request( - { - host: '127.0.0.1', - port: MGMT_PORT, - path: '/v1/local/health', - method: 'GET', - timeout: 500, - }, - function (res) { - const chunks = []; - let bytes = 0; - res.on('data', function (chunk) { - if (settled) return; - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8'); - bytes += buffer.length; - if (bytes > 4096) { - chunks.length = 0; - req.destroy(); - finish(false); - return; - } - chunks.push(buffer); - }); - res.on('end', function () { - if (settled) return; - try { - const body = Buffer.concat(chunks, bytes).toString('utf8'); - const parsed = JSON.parse(body || '{}'); - finish(res.statusCode >= 200 && res.statusCode < 300 && parsed.appId === APP_ID); - } catch (err) { - finish(false); - } - }); - } - ); - req.on('timeout', function () { - req.destroy(); - finish(false); - }); - req.on('error', function () { - finish(false); - }); - req.end(); - }; - - const startServers = function () { - const tlsConfig = ensureCert(); - const servers = []; - - // Wire safety-module shutdown hook to our local shutdown - try { - exceptionHandlers.setShutdownHook(function (reason) { - shutdown(); - }); - } catch (e) {} - - function shutdown() { - let remaining = servers.length; - if (!remaining) process.exit(0); - for (const server of servers) { - try { - server.close(function () { - remaining -= 1; - if (remaining === 0) process.exit(0); - }); - } catch (err) { - remaining -= 1; - if (remaining === 0) process.exit(0); - } - } - } - - function handleListenError(serverName, err) { - if (err && err.code === 'EADDRINUSE') { - log.error( - '[startup] ' + - serverName + - ' is already in use. Stop the owning process or change the configured port.' - ); - process.exit(1); - return; - } - log.error('[startup] ' + serverName + ' failed: ' + (err && err.message ? err.message : err)); - process.exit(1); - } - - function listenEntry(entry) { - const factory = entry.tls === false ? http : https; - const server = - entry.tls === false - ? factory.createServer(function (req, res) { - handleRequest(req, res, entry); - }) - : factory.createServer(tlsConfig, function (req, res) { - handleRequest(req, res, entry); - }); - servers.push(server); - if (entry.tls !== false) ACTIVE_HTTPS_SERVERS.push(server); - const listenHost = entry.host === 'localhost' ? '127.0.0.1' : entry.host; - server.on('error', function (err) { - handleListenError('port ' + listenHost + ':' + entry.listenPort, err); - }); - server.listen(entry.listenPort, listenHost, function () { - log.info( - ' Listen: ' + - (entry.tls === false ? 'http' : 'https') + - '://' + - entry.host + - ':' + - entry.listenPort + - entry.basePath + - ' [' + - entry.protocol + - ']' - ); - }); - return server; - } - - log.info(''); - log.info('+-----------------------------------------------+'); - log.info('| OpenAI HTTPS Proxy |'); - log.info('+-----------------------------------------------+'); - log.info(''); - - const envEntry = process.env.PORT ? [{ ...LOCAL_SERVER, listenPort: PORT }] : ENTRY_SERVERS; - for (const entry of envEntry) listenEntry(entry); - - // ── HTTP 管理端口 ────────────────────────────────── - // 纯 HTTP 服务器,仅处理 /v1/local/* 管理 API,不转发代理请求。 - // 解决 Tauri 生产模式下 webview 无法访问自签名 HTTPS 的问题。 - const mgmtServer = http.createServer(function (req, res) { - const urlPath = req.url || ''; - if (!installManagementCorsGuard(req, res)) return; - if (req.method === 'OPTIONS') { - res.writeHead(204); - res.end(); - return; - } - // 只允许 /v1/local/* 管理端点 - if (urlPath.startsWith('/v1/local/') || urlPath.startsWith('/local/')) { - handleRequest(req, res, envEntry[0] || LOCAL_SERVER); - return; - } - // 拒绝其他所有请求(不允许通过 HTTP 管理端口做代理转发) - res.writeHead(403, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Management port only accepts /v1/local/* requests' })); - }); - servers.push(mgmtServer); - mgmtServer.on('error', function (err) { - handleListenError('management port 127.0.0.1:' + MGMT_PORT, err); - }); - // Write the management token to disk BEFORE starting to listen so - // dev-static-server.js (and any other reader) sees the new token the - // moment the port is reachable. Without this, there's a small window - // where the server is accepting connections but the token file still - // holds the previous process's token, and any forwarded management - // request gets 401 Unauthorized until the new token is written. - writeManagementTokenFile(MGMT_PORT); - mgmtServer.listen(MGMT_PORT, '127.0.0.1', function () { - writeManagementTokenFile(MGMT_PORT); - log.info(' Mgmt: http://127.0.0.1:' + MGMT_PORT + '/v1/local/* [management-http]'); - }); - - process.nextTick(function () { - log.info(''); - log.info(' Profile: ' + (ACTIVE_PROFILE.name || ACTIVE_PROFILE.id || 'default')); - log.info(' Target: ' + ACTIVE_PROFILE.targetUrl); - log.info(' Routes: ' + MODEL_ROUTES.length); - log.info(''); - log.info(' SDK Config:'); - for (const entry of envEntry) { - log.info( - ' ' + - entry.protocol + - ': ' + - (entry.tls === false ? 'http' : 'https') + - '://' + - entry.host + - ':' + - entry.listenPort + - entry.basePath - ); - } - log.info(' management: http://127.0.0.1:' + MGMT_PORT + '/v1/local/*'); - log.info(' mgmt_token_file: ' + path.join(DATA_DIR, `.mgmt-token-${MGMT_PORT}`)); - log.info(' mgmt_token_hint: ' + maskManagementToken(MANAGEMENT_TOKEN)); - log.info(' openai.api_key = "your key"'); - log.info(''); - log.info(' Ctrl+C to stop'); - log.info(''); - }); - - process.on('SIGINT', function () { - log.info('\nStopping...'); - shutdown(); - }); - process.on('SIGTERM', shutdown); - }; - - checkExistingManagementServer(function (isRunning) { - if (isRunning) { - log.info( - '[single-instance] Existing proxy management endpoint detected on 127.0.0.1:' + - MGMT_PORT + - '; exiting duplicate process.' - ); - process.exit(0); - return; - } - startServers(); + startProxyServers({ + ACTIVE_HTTPS_SERVERS, + APP_ID, + DATA_DIR, + ensureCert, + exceptionHandlers, + handleRequest, + installManagementCorsGuard, + log, + maskManagementToken, + parsePortOrDefault, + writeManagementTokenFile, + getRuntimeState: () => ({ + ACTIVE_PROFILE, + CONFIG, + ENTRY_SERVERS, + LOCAL_SERVER, + MANAGEMENT_TOKEN, + MODEL_ROUTES, + PORT, + }), }); } diff --git a/package-lock.json b/package-lock.json index 22228d45..edcf905f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "devDependencies": { "@tauri-apps/cli": "^2.11.2", "eslint": "^9.39.4", + "eslint-plugin-unused-imports": "^4.4.1", "husky": "^9.1.7", "lint-staged": "^15.5.2", "prettier": "^3.8.3", @@ -1680,6 +1681,22 @@ } } }, + "node_modules/eslint-plugin-unused-imports": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz", + "integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", + "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", diff --git a/package.json b/package.json index 7d1d8ed2..6bafd6b1 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,17 @@ "scripts": { "postinstall": "npm run frontend:sync", "start": "node openai-proxy.js", - "test": "npm run frontend:check && npm run test:unit && npm run test:thinking && npm run test:env", - "test:unit": "node scripts/run-unit-tests.js", + "test": "npm run frontend:check && npm run test:unit && npm run test:frontend && npm run test:thinking && npm run test:env", + "test:unit": "node scripts/run-unit-test-shards.js", + "test:unit:serial": "node scripts/run-unit-tests.js", + "test:frontend": "node tauri-ui/src/utils.test.js", + "test:rust": "cargo test --manifest-path src-tauri/Cargo.toml", "test:thinking": "node test/thinking-persistence.test.js", "test:env": "node test/env-tools.test.js && node test/tool-configs.test.js", "test:integration": "node test/headers-routing.test.js && node test/entry-auth-integration.test.js", "test:all": "npm test && npm run test:integration", - "check:syntax": "node scripts/check-syntax.js", - "verify": "npm run check:syntax && npm run lint && npm run test:all", + "check:syntax": "node scripts/check-syntax-parallel.js", + "verify": "npm run check:syntax && npm run lint && npm run release:check && npm run test:all && npm run frontend:build && npm run release:prepare && npm run test:rust", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write \"**/*.{js,cjs,mjs,json,md}\" --ignore-path .prettierignore", @@ -25,13 +28,19 @@ "frontend:preview": "vite preview", "cert:trust:gateway": "node scripts/install-gateway-ca.js", "dev": "npm run frontend:check && npx tauri dev", - "build": "npm run frontend:check && npx tauri build", + "build": "npm run tauri:build", "tauri:dev": "npm run frontend:check && npx tauri dev", - "tauri:build": "npm run frontend:check && npx tauri build" + "tauri:build": "node scripts/build-tauri-release.js", + "clean:release": "node scripts/clean-release-artifacts.js", + "clean:release:cache": "node scripts/clean-release-artifacts.js --cargo-only", + "release:prepare": "node scripts/prepare-release-runtime.js", + "release:check": "node scripts/check-release-governance.js", + "release:portable": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build-portable.ps1" }, "devDependencies": { "@tauri-apps/cli": "^2.11.2", "eslint": "^9.39.4", + "eslint-plugin-unused-imports": "^4.4.1", "husky": "^9.1.7", "lint-staged": "^15.5.2", "prettier": "^3.8.3", diff --git a/proxy.config.example.json b/proxy.config.example.json index ed47c089..02d78b34 100644 --- a/proxy.config.example.json +++ b/proxy.config.example.json @@ -100,6 +100,21 @@ "from": "default", "profileId": "example-openai", "to": "gpt-5.4" + }, + { + "from": "gpt-5.6-sol", + "profileId": "example-openai", + "to": "gpt-5.6-sol" + }, + { + "from": "gpt-5.6-terra", + "profileId": "example-openai", + "to": "gpt-5.6-terra" + }, + { + "from": "gpt-5.6-luna", + "profileId": "example-openai", + "to": "gpt-5.6-luna" } ], "profiles": [ diff --git a/scripts/build-portable.ps1 b/scripts/build-portable.ps1 index 7350bb7b..648f37d5 100644 --- a/scripts/build-portable.ps1 +++ b/scripts/build-portable.ps1 @@ -1,245 +1,143 @@ param( [switch]$SkipBuild, [switch]$NoZip, - [string]$OutputRoot = "dist\portable" + [string]$OutputRoot = "dist\portable", + [string]$CargoTargetDir = $env:CARGO_TARGET_DIR ) $ErrorActionPreference = 'Stop' -$Root = Split-Path -Parent $PSScriptRoot -$ReleaseDir = Join-Path $Root 'src-tauri\target\release' -$PreferredExe = Join-Path $ReleaseDir 'https-proxy-manager.exe' -$FallbackExe = Join-Path $ReleaseDir 'app.exe' -$Exe = if (Test-Path $PreferredExe) { $PreferredExe } elseif (Test-Path $FallbackExe) { $FallbackExe } else { $PreferredExe } +$Root = [System.IO.Path]::GetFullPath((Split-Path -Parent $PSScriptRoot)) +$DistRoot = [System.IO.Path]::GetFullPath((Join-Path $Root 'dist')) +$ResolvedOutputRoot = [System.IO.Path]::GetFullPath((Join-Path $Root $OutputRoot)) $PortableName = 'HTTPS Proxy Manager Portable' -$PortableDir = Join-Path $Root (Join-Path $OutputRoot $PortableName) -$RuntimeDir = Join-Path $PortableDir 'runtime' -$StateBackupDir = Join-Path ([System.IO.Path]::GetTempPath()) ("hpm-portable-state-" + [guid]::NewGuid().ToString('N')) +$PortableDir = Join-Path $ResolvedOutputRoot $PortableName +$ReleaseRuntimeDir = Join-Path $DistRoot 'release-runtime' -function Copy-RequiredFile { - param([string]$Source, [string]$Destination) - if (-not (Test-Path $Source)) { throw "Missing file: $Source" } - Copy-Item $Source $Destination -Force -} - -function Copy-CommandBinary { - param([string]$CommandName, [string]$DestinationDir) - $command = Get-Command $CommandName -ErrorAction SilentlyContinue - if (-not $command -or -not $command.Source -or -not (Test-Path $command.Source)) { return $false } - New-Item -ItemType Directory -Path $DestinationDir -Force | Out-Null - Copy-Item $command.Source (Join-Path $DestinationDir (Split-Path -Leaf $command.Source)) -Force - return $true -} - -function Copy-NodeRuntimeModule { - param([string]$ModuleName) - $source = Join-Path $Root "node_modules\$ModuleName" - if (-not (Test-Path $source)) { throw "Missing runtime module: $ModuleName. Run npm install first." } - $destination = Join-Path $PortableDir "node_modules\$ModuleName" - New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null - New-Item -ItemType Directory -Path $destination -Force | Out-Null - Copy-Item (Join-Path $source 'lib') (Join-Path $destination 'lib') -Recurse -Force - Copy-Item (Join-Path $source 'package.json') (Join-Path $destination 'package.json') -Force - Copy-Item (Join-Path $source 'LICENSE') (Join-Path $destination 'LICENSE') -Force +function Assert-PathInside { + param( + [string]$Parent, + [string]$Candidate, + [string]$Label + ) + $parentPath = [System.IO.Path]::GetFullPath($Parent).TrimEnd('\') + '\' + $candidatePath = [System.IO.Path]::GetFullPath($Candidate) + if (-not $candidatePath.StartsWith($parentPath, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "$Label must stay inside $Parent`: $candidatePath" + } + return $candidatePath } -function Write-RuntimePackageJson { - $sourcePackage = Get-Content (Join-Path $Root 'package.json') -Raw | ConvertFrom-Json - $runtimePackage = [ordered]@{ - name = $sourcePackage.name - version = $sourcePackage.version - private = $true - dependencies = [ordered]@{ - 'node-forge' = $sourcePackage.dependencies.'node-forge' - } - } - $runtimePackage | ConvertTo-Json -Depth 5 | Set-Content -Path (Join-Path $PortableDir 'package.json') -Encoding UTF8 +function Copy-RequiredFile { + param([string]$Source, [string]$Destination) + if (-not (Test-Path -LiteralPath $Source -PathType Leaf)) { throw "Missing file: $Source" } + $destinationParent = Split-Path -Parent $Destination + if ($destinationParent) { New-Item -ItemType Directory -Path $destinationParent -Force | Out-Null } + Copy-Item -LiteralPath $Source -Destination $Destination -Force } function Assert-PortableFileMatches { param([string]$RelativePath) $source = Join-Path $Root $RelativePath $destination = Join-Path $PortableDir $RelativePath - if (-not (Test-Path $source)) { throw "Missing source file for portable verification: $source" } - if (-not (Test-Path $destination)) { throw "Missing portable file after copy: $destination" } - $sourceHash = (Get-FileHash $source -Algorithm SHA256).Hash - $destinationHash = (Get-FileHash $destination -Algorithm SHA256).Hash - if ($sourceHash -ne $destinationHash) { + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { throw "Missing source file: $source" } + if (-not (Test-Path -LiteralPath $destination -PathType Leaf)) { throw "Missing portable file: $destination" } + if ((Get-FileHash $source -Algorithm SHA256).Hash -ne (Get-FileHash $destination -Algorithm SHA256).Hash) { throw "Portable backend mismatch: $RelativePath" } } -function Test-ExampleProxyConfig { - param([string]$Path) - if (-not (Test-Path $Path)) { return $false } - try { - $json = Get-Content $Path -Raw | ConvertFrom-Json - if ($json.activeProfile -and ([string]$json.activeProfile).StartsWith('example-', [System.StringComparison]::OrdinalIgnoreCase)) { - return $true - } - if ($json.profiles) { - foreach ($profile in @($json.profiles)) { - if ($profile.id -eq 'example-openai' -or ([string]$profile.targetUrl) -like '*api.example.com*') { - return $true - } - } - } - } catch {} - return $false -} - -function Get-JsonConfig { - param([string]$Path) - if (-not (Test-Path $Path)) { return $null } - try { - return Get-Content $Path -Raw | ConvertFrom-Json - } catch { - return $null - } -} - -function Test-ConfigHasEntryAuth { - param([object]$Config) - if (-not $Config -or -not $Config.entryAuth) { return $false } - if ($Config.entryAuth.enabled -ne $true) { return $false } - return @($Config.entryAuth.keys).Count -gt 0 -} +$ResolvedOutputRoot = Assert-PathInside $DistRoot $ResolvedOutputRoot 'Portable output root' +$PortableDir = Assert-PathInside $ResolvedOutputRoot $PortableDir 'Portable directory' -function Copy-EntryAuthFromRootConfig { - param([string]$TargetConfig) - $RootConfigPath = Join-Path $Root 'proxy.config.json' - $RootConfig = Get-JsonConfig $RootConfigPath - if (-not (Test-ConfigHasEntryAuth $RootConfig)) { return $false } - - $Target = Get-JsonConfig $TargetConfig - if (-not $Target) { - Copy-Item $RootConfigPath $TargetConfig -Force - return $true - } - if (Test-ConfigHasEntryAuth $Target) { return $false } - - $Target | Add-Member -NotePropertyName 'entryAuth' -NotePropertyValue $RootConfig.entryAuth -Force - $Target | ConvertTo-Json -Depth 20 | Set-Content -Path $TargetConfig -Encoding UTF8 - return $true -} - -function Copy-PortableConfigBackup { - param( - [string]$ExistingConfig, - [string]$BackupConfig - ) - $RootConfig = Join-Path $Root 'proxy.config.json' - if ((Test-ExampleProxyConfig $ExistingConfig) -and (Test-Path $RootConfig) -and -not (Test-ExampleProxyConfig $RootConfig)) { - Copy-Item $RootConfig $BackupConfig -Force - return - } - if (Test-Path $ExistingConfig) { - Copy-Item $ExistingConfig $BackupConfig -Force - Copy-EntryAuthFromRootConfig $BackupConfig | Out-Null +Push-Location $Root +try { + if (-not $SkipBuild) { + npm run tauri:build + if ($LASTEXITCODE -ne 0) { throw "Tauri build failed with exit code $LASTEXITCODE" } + } else { + npm run release:prepare + if ($LASTEXITCODE -ne 0) { throw "Release runtime preparation failed with exit code $LASTEXITCODE" } } +} finally { + Pop-Location } -function Clear-PortableGeneratedFiles { - param([string]$Path) - if (-not (Test-Path $Path)) { return } - $stateNames = @('.hpm-runtime', 'proxy.config.json') - Get-ChildItem -LiteralPath $Path -Force | ForEach-Object { - if ($stateNames -contains $_.Name) { return } - Remove-Item -LiteralPath $_.FullName -Recurse -Force - } +$ResolvedCargoTarget = if ([string]::IsNullOrWhiteSpace($CargoTargetDir)) { + Join-Path $Root 'src-tauri\target' +} elseif ([System.IO.Path]::IsPathRooted($CargoTargetDir)) { + [System.IO.Path]::GetFullPath($CargoTargetDir) +} else { + [System.IO.Path]::GetFullPath((Join-Path $Root $CargoTargetDir)) } +$ReleaseDir = Join-Path $ResolvedCargoTarget 'release' +$PreferredExe = Join-Path $ReleaseDir 'https-proxy-manager.exe' +$FallbackExe = Join-Path $ReleaseDir 'app.exe' +$Exe = if (Test-Path -LiteralPath $PreferredExe -PathType Leaf) { $PreferredExe } else { $FallbackExe } -if (-not $SkipBuild) { - Push-Location $Root - try { - npm run tauri:build - } finally { - Pop-Location - } +if (-not (Test-Path -LiteralPath $Exe -PathType Leaf)) { + throw "Release executable not found under $ReleaseDir. Build first or pass -CargoTargetDir." } - -if (-not (Test-Path $Exe)) { - throw "Release exe not found: $Exe. Run npm run tauri:build first, or remove -SkipBuild." +if (-not (Test-Path -LiteralPath $ReleaseRuntimeDir -PathType Container)) { + throw "Release runtime not found: $ReleaseRuntimeDir" } -New-Item -ItemType Directory -Path $StateBackupDir -Force | Out-Null -$ExistingConfig = Join-Path $PortableDir 'proxy.config.json' -$ExistingCertDir = Join-Path $PortableDir '.hpm-runtime\.certs' -Copy-PortableConfigBackup $ExistingConfig (Join-Path $StateBackupDir 'proxy.config.json') -if (Test-Path $ExistingCertDir) { - Copy-Item $ExistingCertDir (Join-Path $StateBackupDir '.certs') -Recurse -Force - $NestedCertBackup = Join-Path $StateBackupDir '.certs\.certs' - if (Test-Path $NestedCertBackup) { Remove-Item $NestedCertBackup -Recurse -Force } +# Release output is always rebuilt from a clean directory. Local config, logs, +# tokens, and certificates are deliberately never carried forward. +if (Test-Path -LiteralPath $PortableDir) { + $safePortableDir = Assert-PathInside $ResolvedOutputRoot $PortableDir 'Portable cleanup target' + Remove-Item -LiteralPath $safePortableDir -Recurse -Force } - -Clear-PortableGeneratedFiles $PortableDir New-Item -ItemType Directory -Path $PortableDir -Force | Out-Null -New-Item -ItemType Directory -Path $RuntimeDir -Force | Out-Null Copy-RequiredFile $Exe (Join-Path $PortableDir 'https-proxy-manager.exe') Copy-RequiredFile (Join-Path $Root 'openai-proxy.js') (Join-Path $PortableDir 'openai-proxy.js') -Write-RuntimePackageJson -Copy-Item (Join-Path $Root 'lib') (Join-Path $PortableDir 'lib') -Recurse -Force -Copy-NodeRuntimeModule 'node-forge' +Copy-Item -LiteralPath (Join-Path $Root 'lib') -Destination (Join-Path $PortableDir 'lib') -Recurse -Force Copy-RequiredFile (Join-Path $Root 'proxy.ps1') (Join-Path $PortableDir 'proxy.ps1') +Copy-RequiredFile (Join-Path $Root 'proxy.bat') (Join-Path $PortableDir 'proxy.bat') Copy-RequiredFile (Join-Path $Root 'proxy.config.example.json') (Join-Path $PortableDir 'proxy.config.example.json') Copy-RequiredFile (Join-Path $Root 'tray-icon.ico') (Join-Path $PortableDir 'tray-icon.ico') -Copy-RequiredFile (Join-Path $Root 'proxy.bat') (Join-Path $PortableDir 'proxy.bat') +Copy-RequiredFile (Join-Path $ReleaseRuntimeDir 'package.json') (Join-Path $PortableDir 'package.json') +Copy-RequiredFile (Join-Path $ReleaseRuntimeDir 'release-runtime-manifest.json') (Join-Path $PortableDir 'release-runtime-manifest.json') +Copy-Item -LiteralPath (Join-Path $ReleaseRuntimeDir 'runtime') -Destination (Join-Path $PortableDir 'runtime') -Recurse -Force +Copy-Item -LiteralPath (Join-Path $ReleaseRuntimeDir 'node_modules') -Destination (Join-Path $PortableDir 'node_modules') -Recurse -Force Assert-PortableFileMatches 'openai-proxy.js' Assert-PortableFileMatches 'lib\proxy\cert.js' Assert-PortableFileMatches 'lib\proxy\cert-management.js' -$BackedUpConfig = Join-Path $StateBackupDir 'proxy.config.json' -$BackedUpCertDir = Join-Path $StateBackupDir '.certs' -if (Test-Path $BackedUpConfig) { - Copy-Item $BackedUpConfig (Join-Path $PortableDir 'proxy.config.json') -Force -} else { - Copy-Item (Join-Path $PortableDir 'proxy.config.example.json') (Join-Path $PortableDir 'proxy.config.json') -Force -} -if (Test-Path $BackedUpCertDir) { - $PortableRuntimeDir = Join-Path $PortableDir '.hpm-runtime' - $PortableCertDir = Join-Path $PortableRuntimeDir '.certs' - New-Item -ItemType Directory -Path $PortableRuntimeDir -Force | Out-Null - if (Test-Path $PortableCertDir) { Remove-Item $PortableCertDir -Recurse -Force } - Copy-Item $BackedUpCertDir $PortableCertDir -Recurse -Force -} - -$nodeCopied = Copy-CommandBinary 'node' (Join-Path $RuntimeDir 'node') - -$nodeStatus = if ($nodeCopied) { 'Bundled runtime\node\node.exe' } else { 'Node.js not found locally; target machine must install Node.js and add it to PATH' } -$modulesStatus = 'Bundled runtime module: node-forge' - $readme = @( 'HTTPS Proxy Manager Portable', '', 'Usage:', '1. Double-click https-proxy-manager.exe to start the manager and local proxy.', - '2. Config, logs, and certificates are stored in this portable folder.', + '2. Config, logs, and certificates are created locally in this portable folder.', '3. Optional CLI maintenance: proxy.bat start|stop|restart|status|logs.', '', - 'Runtime:', - "- Node.js: $nodeStatus", - "- Node modules: $modulesStatus", - '- PowerShell: uses pwsh when available; otherwise falls back to Windows PowerShell.', - '- Certificates: generated with bundled Node.js dependencies; OpenSSL is not required.', + 'Security:', + '- This release is stateless and contains no proxy.config.json, API keys, tokens, logs, or certificates.', + '- First start creates proxy.config.json from proxy.config.example.json.', + '- Do not redistribute the folder after adding credentials or generating certificates.', '', - 'Notes:', - '- First proxy start creates proxy.config.json from proxy.config.example.json.', - '- proxy.config.json is local user state and may contain API keys; do not redistribute it.', - '- First run creates .hpm-runtime/.certs. Install the certificate from the UI if clients need trusted HTTPS.' + 'Runtime:', + '- Node.js and all production npm dependencies are bundled.', + '- OpenSSL is not required for local certificate generation.' ) -join [Environment]::NewLine Set-Content -Path (Join-Path $PortableDir 'README.txt') -Value $readme -Encoding UTF8 +Push-Location $Root +try { + node scripts/validate-release-package.js --dir $PortableDir + if ($LASTEXITCODE -ne 0) { throw "Portable release validation failed with exit code $LASTEXITCODE" } +} finally { + Pop-Location +} + if (-not $NoZip) { - $ZipPath = Join-Path $Root (Join-Path $OutputRoot 'HTTPS-Proxy-Manager-Portable.zip') - if (Test-Path $ZipPath) { Remove-Item $ZipPath -Force } + $ZipPath = Assert-PathInside $DistRoot (Join-Path $ResolvedOutputRoot 'HTTPS-Proxy-Manager-Portable.zip') 'Portable zip path' + if (Test-Path -LiteralPath $ZipPath) { Remove-Item -LiteralPath $ZipPath -Force } Compress-Archive -Path $PortableDir -DestinationPath $ZipPath -Force Write-Host "Portable package generated: $PortableDir" Write-Host "Zip generated: $ZipPath" } else { Write-Host "Portable package generated: $PortableDir" } - -if (-not $nodeCopied) { Write-Warning 'Node.js was not bundled; target machine must install Node.js.' } - -Remove-Item $StateBackupDir -Recurse -Force -ErrorAction SilentlyContinue diff --git a/scripts/build-tauri-release.js b/scripts/build-tauri-release.js new file mode 100644 index 00000000..41a24181 --- /dev/null +++ b/scripts/build-tauri-release.js @@ -0,0 +1,22 @@ +'use strict'; + +const { spawnSync } = require('child_process'); +const path = require('path'); +const { collectTauriArtifacts, resolveCargoTargetDir } = require('./collect-tauri-artifacts'); + +const repoRoot = path.resolve(__dirname, '..'); +const cargoTargetDir = resolveCargoTargetDir(repoRoot, process.env.CARGO_TARGET_DIR); +const tauriCli = require.resolve('@tauri-apps/cli/tauri.js'); +const result = spawnSync(process.execPath, [tauriCli, 'build'], { + cwd: repoRoot, + env: { ...process.env, CARGO_TARGET_DIR: cargoTargetDir }, + stdio: 'inherit', +}); + +if (result.status !== 0) { + const detail = result.error && result.error.message ? `: ${result.error.message}` : ''; + throw new Error(`Tauri release build failed with exit code ${result.status}${detail}`); +} + +const collected = collectTauriArtifacts(repoRoot, cargoTargetDir); +console.log(`[tauri-build] complete: ${collected.manifest.length} installer(s)`); diff --git a/scripts/check-release-governance.js b/scripts/check-release-governance.js new file mode 100644 index 00000000..1c0c7ac5 --- /dev/null +++ b/scripts/check-release-governance.js @@ -0,0 +1,55 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.resolve(__dirname, '..'); +const tauriConfig = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'src-tauri', 'tauri.conf.json'), 'utf8') +); +const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); +const resources = tauriConfig.bundle && tauriConfig.bundle.resources; +const errors = []; + +if (!resources || typeof resources !== 'object' || Array.isArray(resources)) { + errors.push('Tauri bundle.resources must use an explicit source-to-target map'); +} else { + const serialized = JSON.stringify(resources); + if (serialized.includes('dist/portable')) + errors.push('Tauri resources must not depend on portable output'); + if (!serialized.includes('dist/release-runtime/runtime')) + errors.push('Tauri resources must include staged Node runtime'); + if (!serialized.includes('dist/release-runtime/node_modules')) + errors.push('Tauri resources must include staged production dependencies'); +} + +const beforeBuild = String((tauriConfig.build && tauriConfig.build.beforeBuildCommand) || ''); +if (!beforeBuild.includes('prepare-release-runtime.js')) { + errors.push('Tauri beforeBuildCommand must prepare a clean release runtime'); +} + +for (const scriptName of [ + 'clean:release', + 'clean:release:cache', + 'release:prepare', + 'release:check', + 'release:portable', +]) { + if (!packageJson.scripts || !packageJson.scripts[scriptName]) { + errors.push(`Missing package script: ${scriptName}`); + } +} + +const verifyScript = String((packageJson.scripts && packageJson.scripts.verify) || ''); +const releasePrepareIndex = verifyScript.indexOf('npm run release:prepare'); +const rustTestIndex = verifyScript.indexOf('npm run test:rust'); +if (releasePrepareIndex < 0 || rustTestIndex < 0 || releasePrepareIndex > rustTestIndex) { + errors.push('verify must prepare release runtime before running Rust tests'); +} + +if (errors.length > 0) { + for (const error of errors) console.error(`[release-governance] ${error}`); + process.exit(1); +} + +console.log('[release-governance] ok'); diff --git a/scripts/check-syntax-parallel.js b/scripts/check-syntax-parallel.js new file mode 100644 index 00000000..08f2ceee --- /dev/null +++ b/scripts/check-syntax-parallel.js @@ -0,0 +1,72 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); + +const repoRoot = path.resolve(__dirname, '..'); +const skippedDirs = new Set(['.git', 'node_modules', 'dist', 'build', 'out', 'target']); +const maxWorkers = Math.max(1, Math.min(8, os.availableParallelism?.() || os.cpus().length || 1)); + +function shouldSkipDirectory(name) { + return skippedDirs.has(name) || name.startsWith('target-'); +} + +function collectJsFiles(dir, output) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (shouldSkipDirectory(entry.name)) continue; + collectJsFiles(path.join(dir, entry.name), output); + } else if (entry.isFile() && entry.name.endsWith('.js')) { + output.push(path.join(dir, entry.name)); + } + } +} + +function checkFile(file) { + return new Promise((resolve) => { + const child = spawn(process.execPath, ['--check', file], { + cwd: repoRoot, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + let output = ''; + child.stdout.on('data', (chunk) => { + output += chunk; + }); + child.stderr.on('data', (chunk) => { + output += chunk; + }); + child.on('error', (error) => resolve({ file, error, output })); + child.on('close', (code) => resolve({ file, code, output })); + }); +} + +async function main() { + const files = []; + collectJsFiles(repoRoot, files); + files.sort(); + + let nextIndex = 0; + let failure = null; + async function worker() { + while (!failure && nextIndex < files.length) { + const file = files[nextIndex++]; + const result = await checkFile(file); + if (result.error || result.code !== 0) failure = result; + } + } + + await Promise.all(Array.from({ length: Math.min(maxWorkers, files.length) }, worker)); + if (failure) { + if (failure.output) process.stderr.write(failure.output); + console.error(`[syntax] failed: ${path.relative(repoRoot, failure.file)}`); + process.exitCode = failure.code || 1; + return; + } + console.log(`[syntax] ok: ${files.length} JavaScript files (${maxWorkers} workers)`); +} + +main().catch((error) => { + console.error(`[syntax] ${error.message}`); + process.exitCode = 1; +}); diff --git a/scripts/check-syntax.js b/scripts/check-syntax.js index bcbda60a..23bc7548 100644 --- a/scripts/check-syntax.js +++ b/scripts/check-syntax.js @@ -3,19 +3,16 @@ const path = require('path'); const { spawnSync } = require('child_process'); const repoRoot = path.resolve(__dirname, '..'); -const skippedDirs = new Set([ - '.git', - 'node_modules', - 'dist', - 'build', - 'out', - 'target', -]); +const skippedDirs = new Set(['.git', 'node_modules', 'dist', 'build', 'out', 'target']); + +function shouldSkipDirectory(name) { + return skippedDirs.has(name) || name.startsWith('target-'); +} function collectJsFiles(dir, output) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.isDirectory()) { - if (skippedDirs.has(entry.name)) continue; + if (shouldSkipDirectory(entry.name)) continue; collectJsFiles(path.join(dir, entry.name), output); } else if (entry.isFile() && entry.name.endsWith('.js')) { output.push(path.join(dir, entry.name)); diff --git a/scripts/clean-release-artifacts.js b/scripts/clean-release-artifacts.js new file mode 100644 index 00000000..6b625e14 --- /dev/null +++ b/scripts/clean-release-artifacts.js @@ -0,0 +1,54 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.resolve(__dirname, '..'); +const tauriRoot = path.join(repoRoot, 'src-tauri'); +const cargoOnly = process.argv.includes('--cargo-only'); +const candidates = cargoOnly ? [] : [path.join(repoRoot, 'dist')]; + +for (const entry of fs.readdirSync(tauriRoot, { withFileTypes: true })) { + if (entry.isDirectory() && (entry.name === 'target' || entry.name.startsWith('target-'))) { + candidates.push(path.join(tauriRoot, entry.name)); + } +} + +const nestedTauriRoot = path.join(tauriRoot, 'src-tauri'); +if (fs.existsSync(nestedTauriRoot)) { + const containsCargoTarget = fs + .readdirSync(nestedTauriRoot, { withFileTypes: true }) + .some( + (entry) => + entry.isDirectory() && (entry.name === 'target' || entry.name.startsWith('target-')) + ); + if (containsCargoTarget) candidates.push(nestedTauriRoot); +} + +const failures = []; +for (const candidate of candidates) { + const resolved = path.resolve(candidate); + const relative = path.relative(repoRoot, resolved); + if ( + !relative || + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new Error(`Refusing to clean path outside workspace: ${resolved}`); + } + try { + fs.rmSync(resolved, { recursive: true, force: true, maxRetries: 3, retryDelay: 250 }); + console.log(`[release-clean] removed ${relative}`); + } catch (error) { + failures.push({ relative, error }); + console.error(`[release-clean] unable to remove ${relative}: ${error.code || error.message}`); + } +} + +if (failures.length > 0) { + console.error( + `[release-clean] ${failures.length} path(s) remain, usually because a built executable is running` + ); + process.exitCode = 1; +} diff --git a/scripts/collect-tauri-artifacts.js b/scripts/collect-tauri-artifacts.js new file mode 100644 index 00000000..b647efab --- /dev/null +++ b/scripts/collect-tauri-artifacts.js @@ -0,0 +1,80 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { assertSafeReleasePath } = require('../lib/release/governance'); + +const INSTALLER_SUFFIXES = ['.exe', '.msi', '.dmg', '.appimage', '.deb', '.rpm', '.tar.gz']; + +function resolveCargoTargetDir(repoRoot, value) { + if (!value) return path.join(repoRoot, 'src-tauri', 'target'); + return path.isAbsolute(value) ? path.resolve(value) : path.resolve(repoRoot, value); +} + +function isInstallerFile(fileName) { + const normalized = String(fileName || '').toLowerCase(); + return INSTALLER_SUFFIXES.some((suffix) => normalized.endsWith(suffix)); +} + +function collectInstallerFiles(bundleDir) { + const files = []; + if (!fs.existsSync(bundleDir)) return files; + function visit(current) { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const absolute = path.join(current, entry.name); + if (entry.isDirectory()) visit(absolute); + else if (entry.isFile() && isInstallerFile(entry.name)) files.push(absolute); + } + } + visit(bundleDir); + return files.sort(); +} + +function sha256(filePath) { + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); +} + +function collectTauriArtifacts(repoRoot, cargoTargetDir) { + const outputDir = assertSafeReleasePath( + repoRoot, + path.join(repoRoot, 'dist', 'release', 'installers') + ); + const bundleDir = path.join(cargoTargetDir, 'release', 'bundle'); + const installers = collectInstallerFiles(bundleDir); + if (installers.length === 0) throw new Error(`No Tauri installers found under ${bundleDir}`); + + fs.rmSync(outputDir, { recursive: true, force: true }); + fs.mkdirSync(outputDir, { recursive: true }); + const manifest = []; + for (const source of installers) { + const target = path.join(outputDir, path.basename(source)); + fs.copyFileSync(source, target); + manifest.push({ + file: path.basename(target), + bytes: fs.statSync(target).size, + sha256: sha256(target), + }); + } + fs.writeFileSync( + path.join(outputDir, 'SHA256SUMS.json'), + JSON.stringify(manifest, null, 2) + '\n' + ); + return { outputDir, manifest }; +} + +if (require.main === module) { + const repoRoot = path.resolve(__dirname, '..'); + const cargoTargetDir = resolveCargoTargetDir(repoRoot, process.env.CARGO_TARGET_DIR); + const result = collectTauriArtifacts(repoRoot, cargoTargetDir); + console.log( + `[tauri-artifacts] collected ${result.manifest.length} installer(s) in ${path.relative(repoRoot, result.outputDir)}` + ); +} + +module.exports = { + collectInstallerFiles, + collectTauriArtifacts, + isInstallerFile, + resolveCargoTargetDir, +}; diff --git a/scripts/install-gateway-ca.js b/scripts/install-gateway-ca.js index e29cd319..b26e1d1c 100644 --- a/scripts/install-gateway-ca.js +++ b/scripts/install-gateway-ca.js @@ -18,8 +18,10 @@ function parseArgs(argv) { return result; } -function defaultRuntimeDir(env = process.env, platform = process.platform) { +function defaultRuntimeDir(env = process.env, platform = process.platform, cwd = process.cwd()) { if (env.HTTPS_PROXY_RUNTIME_DIR) return path.resolve(env.HTTPS_PROXY_RUNTIME_DIR); + const workspaceRuntime = path.join(path.resolve(cwd), '.hpm-runtime'); + if (fs.existsSync(workspaceRuntime)) return workspaceRuntime; if (platform === 'win32' && env.LOCALAPPDATA) { const installedRuntime = path.join(env.LOCALAPPDATA, 'HTTPS Proxy Manager', '.hpm-runtime'); if (fs.existsSync(installedRuntime)) return installedRuntime; diff --git a/scripts/prepare-release-runtime.js b/scripts/prepare-release-runtime.js new file mode 100644 index 00000000..84c92aa7 --- /dev/null +++ b/scripts/prepare-release-runtime.js @@ -0,0 +1,76 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { assertSafeReleasePath } = require('../lib/release/governance'); + +const repoRoot = path.resolve(__dirname, '..'); +const outputDir = assertSafeReleasePath(repoRoot, path.join(repoRoot, 'dist', 'release-runtime')); +const sourcePackage = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); +const runtimePackage = { + name: sourcePackage.name, + version: sourcePackage.version, + private: true, + dependencies: sourcePackage.dependencies || {}, +}; + +function sha256(filePath) { + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); +} + +function runNpmCi() { + const npmCliCandidates = [ + process.env.npm_execpath, + path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'), + ].filter(Boolean); + const npmCli = npmCliCandidates.find((candidate) => fs.existsSync(candidate)); + if (!npmCli) throw new Error('Unable to locate npm-cli.js for production dependency staging'); + const result = spawnSync( + process.execPath, + [npmCli, 'ci', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'], + { cwd: outputDir, stdio: 'inherit' } + ); + if (result.status !== 0) { + const detail = result.error && result.error.message ? `: ${result.error.message}` : ''; + throw new Error( + `Production dependency staging failed with exit code ${result.status}${detail}` + ); + } +} + +fs.rmSync(outputDir, { recursive: true, force: true }); +fs.mkdirSync(path.join(outputDir, 'runtime', 'node'), { recursive: true }); +fs.writeFileSync( + path.join(outputDir, 'package.json'), + JSON.stringify(runtimePackage, null, 2) + '\n' +); +fs.copyFileSync( + path.join(repoRoot, 'package-lock.json'), + path.join(outputDir, 'package-lock.json') +); + +const nodeTarget = path.join(outputDir, 'runtime', 'node', 'node.exe'); +fs.copyFileSync(process.execPath, nodeTarget); +runNpmCi(); + +for (const dependency of Object.keys(runtimePackage.dependencies)) { + const packageFile = path.join(outputDir, 'node_modules', dependency, 'package.json'); + if (!fs.existsSync(packageFile)) + throw new Error(`Missing staged production dependency: ${dependency}`); +} + +const manifest = { + schemaVersion: 1, + applicationVersion: sourcePackage.version, + nodeVersion: process.version, + nodeSha256: sha256(nodeTarget), + dependencies: runtimePackage.dependencies, +}; +fs.writeFileSync( + path.join(outputDir, 'release-runtime-manifest.json'), + JSON.stringify(manifest, null, 2) + '\n' +); + +console.log(`[release-runtime] prepared ${path.relative(repoRoot, outputDir)}`); diff --git a/scripts/run-unit-test-shards.js b/scripts/run-unit-test-shards.js new file mode 100644 index 00000000..04653874 --- /dev/null +++ b/scripts/run-unit-test-shards.js @@ -0,0 +1,48 @@ +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); + +const repoRoot = path.resolve(__dirname, '..'); +const runner = path.join(__dirname, 'run-unit-tests.js'); +const DEFAULT_SHARD_COUNT = 4; + +function runShard(index, count) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [runner], { + cwd: repoRoot, + env: { + ...process.env, + TEST_SHARD_COUNT: String(count), + TEST_SHARD_INDEX: String(index), + }, + stdio: 'inherit', + windowsHide: true, + }); + child.once('error', (error) => { + console.error(`[unit] shard ${index + 1}/${count} failed to start: ${error.message}`); + resolve(1); + }); + child.once('close', (code) => resolve(code || 0)); + }); +} + +async function main() { + if (process.env.TEST_SHARD_COUNT || process.env.TEST_SHARD_INDEX) { + const count = process.env.TEST_SHARD_COUNT || '1'; + const index = process.env.TEST_SHARD_INDEX || '0'; + process.exitCode = await runShard(index, count); + return; + } + + const available = os.availableParallelism?.() || os.cpus().length || 1; + const shardCount = Math.max(1, Math.min(DEFAULT_SHARD_COUNT, available)); + const exitCodes = await Promise.all( + Array.from({ length: shardCount }, (_, index) => runShard(index, shardCount)) + ); + process.exitCode = exitCodes.some((code) => code !== 0) ? 1 : 0; +} + +main().catch((error) => { + console.error(`[unit] shard runner failed: ${error.message}`); + process.exitCode = 1; +}); diff --git a/scripts/run-unit-tests.js b/scripts/run-unit-tests.js index 8d9ef143..9a7cf48b 100644 --- a/scripts/run-unit-tests.js +++ b/scripts/run-unit-tests.js @@ -1,6 +1,7 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); +const { selectTestShard } = require('../lib/test-sharding'); const repoRoot = path.resolve(__dirname, '..'); const testDir = path.join(repoRoot, 'test'); @@ -22,10 +23,21 @@ const unitTests = [ 'test/config-watcher.test.js', 'test/eslint-config.test.js', 'test/runtime-migration.test.js', + 'test/release-governance.test.js', + 'test/tauri-release-wiring.test.js', + 'test/installer-process-cleanup.test.js', 'test/conversation-audit-utils.test.js', 'test/cli-session-routing.test.js', 'test/upstream-request.test.js', 'test/upstream-retry.test.js', + 'test/test-sharding.test.js', + 'test/proxy-transport.test.js', + 'test/core-request-handler.test.js', + 'test/server-lifecycle.test.js', + 'test/runtime-support.test.js', + 'test/request-observability.test.js', + 'test/runtime-config-controller.test.js', + 'test/claude-self-test-target.test.js', 'test/route-index.test.js', 'test/route-gemini-utils.test.js', 'test/responses-gemini-code-assist.test.js', @@ -73,6 +85,7 @@ const unitTests = [ 'test/management-mcp.test.js', 'test/management-profile.test.js', 'test/management-models.test.js', + 'test/builtin-model-routes.test.js', 'test/management-latency.test.js', 'test/management-credentials.test.js', 'test/management-rate-limits.test.js', @@ -137,7 +150,15 @@ function assertUnitTestList() { assertUnitTestList(); -for (const testFile of unitTests) { +let shard; +try { + shard = selectTestShard(unitTests, process.env); +} catch (error) { + console.error(`[unit] ${error.message}`); + process.exit(1); +} + +for (const testFile of shard.tests) { const result = spawnSync(process.execPath, [testFile], { cwd: repoRoot, stdio: 'inherit', @@ -148,4 +169,6 @@ for (const testFile of unitTests) { } } -console.log(`[unit] ok: ${unitTests.length} test files`); +console.log( + `[unit] ok: ${shard.tests.length}/${unitTests.length} test files (shard ${shard.index + 1}/${shard.count})` +); diff --git a/scripts/validate-release-package.js b/scripts/validate-release-package.js new file mode 100644 index 00000000..f193fae4 --- /dev/null +++ b/scripts/validate-release-package.js @@ -0,0 +1,27 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { assertSafeReleasePath, validateReleaseDirectory } = require('../lib/release/governance'); + +const repoRoot = path.resolve(__dirname, '..'); +const argumentIndex = process.argv.indexOf('--dir'); +if (argumentIndex < 0 || !process.argv[argumentIndex + 1]) { + throw new Error('Usage: node scripts/validate-release-package.js --dir '); +} + +const releaseDir = assertSafeReleasePath( + repoRoot, + path.resolve(repoRoot, process.argv[argumentIndex + 1]) +); +const sourcePackage = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); +const result = validateReleaseDirectory(releaseDir, sourcePackage.dependencies, { + requireExecutable: process.argv.includes('--runtime-only') === false, +}); + +if (!result.ok) { + for (const error of result.errors) console.error(`[release-check] ${error}`); + process.exit(1); +} + +console.log(`[release-check] ok: ${path.relative(repoRoot, releaseDir)}`); diff --git a/src-tauri/nsis/installer-hooks.nsh b/src-tauri/nsis/installer-hooks.nsh new file mode 100644 index 00000000..972f0ab3 --- /dev/null +++ b/src-tauri/nsis/installer-hooks.nsh @@ -0,0 +1,27 @@ +!define HTTPS_PROXY_INSTALLER_HOOK_DIR "${__FILEDIR__}" + +!macro StopInstalledHttpsProxyManager + InitPluginsDir + File /oname=$PLUGINSDIR\stop-installed-processes.ps1 "${HTTPS_PROXY_INSTALLER_HOOK_DIR}\stop-installed-processes.ps1" + DetailPrint "Stopping existing HTTPS Proxy Manager processes..." + StrCpy $R9 "$LOCALAPPDATA\HTTPS Proxy Manager" + DetailPrint "Process cleanup directory: $R9" + nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\stop-installed-processes.ps1"' + Pop $0 + Pop $1 + ${If} $1 != "" + DetailPrint "Process cleanup diagnostics: $1" + ${EndIf} + ${If} $0 != 0 + DetailPrint "Failed to stop existing HTTPS Proxy Manager processes (exit code $0)." + Abort "旧版 HTTPS Proxy Manager 仍在运行或文件被占用。安装程序已自动重试,请打开详细信息查看原因后重试。" + ${EndIf} +!macroend + +!macro NSIS_HOOK_PREINSTALL + !insertmacro StopInstalledHttpsProxyManager +!macroend + +!macro NSIS_HOOK_PREUNINSTALL + !insertmacro StopInstalledHttpsProxyManager +!macroend diff --git a/src-tauri/nsis/stop-installed-processes.ps1 b/src-tauri/nsis/stop-installed-processes.ps1 new file mode 100644 index 00000000..7f595267 --- /dev/null +++ b/src-tauri/nsis/stop-installed-processes.ps1 @@ -0,0 +1,269 @@ +[CmdletBinding()] +param( + [string]$InstallDir = '', + + [string]$FallbackInstallDir = '', + + [ValidateRange(5, 60)] + [int]$TimeoutSeconds = 20 +) + +$ErrorActionPreference = 'Stop' + +function Resolve-NormalizedPath { + param([string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + try { + $fullPath = [System.IO.Path]::GetFullPath($Value) + $pathRoot = [System.IO.Path]::GetPathRoot($fullPath) + # Keep a filesystem root as C:\, never C:. On Windows, C: is relative + # to that drive's current directory. NSIS sets its working directory to + # $INSTDIR, so trimming the slash can make a normal install directory + # compare equal to the filesystem root. + if ( + $pathRoot -and + [string]::Equals( + $fullPath, + $pathRoot, + [System.StringComparison]::OrdinalIgnoreCase + ) + ) { + return $pathRoot + } + $trimCharacters = [char[]]@( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar + ) + return $fullPath.TrimEnd($trimCharacters) + } catch { + return '' + } +} + +function Test-PathEquals { + param([string]$Left, [string]$Right) + $leftPath = Resolve-NormalizedPath $Left + $rightPath = Resolve-NormalizedPath $Right + return $leftPath -and $rightPath -and [string]::Equals( + $leftPath, + $rightPath, + [System.StringComparison]::OrdinalIgnoreCase + ) +} + +$requestedInstallDir = $InstallDir +if ([string]::IsNullOrWhiteSpace($requestedInstallDir)) { + if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + throw 'LOCALAPPDATA is unavailable and InstallDir was not supplied' + } + $requestedInstallDir = Join-Path $env:LOCALAPPDATA 'HTTPS Proxy Manager' +} + +$resolvedInstallDir = Resolve-NormalizedPath $requestedInstallDir +if (-not $resolvedInstallDir) { throw 'InstallDir must be a valid absolute path' } +$installRoot = Resolve-NormalizedPath ([System.IO.Path]::GetPathRoot($resolvedInstallDir)) +if (Test-PathEquals $resolvedInstallDir $installRoot) { + $resolvedFallbackInstallDir = Resolve-NormalizedPath $FallbackInstallDir + $fallbackRoot = if ($resolvedFallbackInstallDir) { + Resolve-NormalizedPath ([System.IO.Path]::GetPathRoot($resolvedFallbackInstallDir)) + } else { + '' + } + if ( + -not $resolvedFallbackInstallDir -or + (Test-PathEquals $resolvedFallbackInstallDir $fallbackRoot) + ) { + throw "InstallDir must not be a filesystem root. Received=[$requestedInstallDir]." + } + Write-Output "Installer supplied filesystem root [$resolvedInstallDir]; using validated fallback [$resolvedFallbackInstallDir]." + $resolvedInstallDir = $resolvedFallbackInstallDir +} +Write-Output "Process cleanup install directory: [$resolvedInstallDir]." +if (-not (Test-Path -LiteralPath $resolvedInstallDir -PathType Container)) { + Write-Output 'Installed application directory is absent; no process cleanup is required.' + exit 0 +} + +$expectedExecutablePaths = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase +) +@( + (Join-Path $resolvedInstallDir 'app.exe'), + (Join-Path $resolvedInstallDir 'HTTPS Proxy Manager.exe'), + (Join-Path $resolvedInstallDir 'https-proxy-manager.exe'), + (Join-Path $resolvedInstallDir 'runtime\node\node.exe') +) | ForEach-Object { + $normalized = Resolve-NormalizedPath $_ + if ($normalized) { [void]$expectedExecutablePaths.Add($normalized) } +} + +function Test-OwnedExecutablePath { + param([string]$ExecutablePath) + $normalized = Resolve-NormalizedPath $ExecutablePath + return $normalized -and $expectedExecutablePaths.Contains($normalized) +} + +function Get-ProcessExecutablePath { + param($Process) + if (-not $Process) { return '' } + try { + $candidate = [string]$Process.Path + if ($candidate) { return $candidate } + } catch {} + try { + return [string]$Process.MainModule.FileName + } catch { + return '' + } +} + +function Get-OwnedProcesses { + $ownedByPid = @{} + $candidateNames = @('app', 'HTTPS Proxy Manager', 'https-proxy-manager', 'node') + + foreach ($process in @(Get-Process -Name $candidateNames -ErrorAction SilentlyContinue)) { + $executablePath = Get-ProcessExecutablePath $process + if (Test-OwnedExecutablePath $executablePath) { + $ownedByPid[[int]$process.Id] = [pscustomobject]@{ + ProcessId = [int]$process.Id + Name = [string]$process.ProcessName + ExecutablePath = Resolve-NormalizedPath $executablePath + Process = $process + } + } + } + + # Process.Path can be unavailable for a process that is exiting or when a + # 32-bit installer inspects a 64-bit process. A narrow CIM query is a safe + # fallback and never makes cleanup depend on enumerating unrelated processes. + try { + $filter = "Name='app.exe' OR Name='HTTPS Proxy Manager.exe' OR Name='https-proxy-manager.exe' OR Name='node.exe'" + foreach ($process in @(Get-CimInstance Win32_Process -Filter $filter -ErrorAction Stop)) { + $executablePath = [string]$process.ExecutablePath + if (Test-OwnedExecutablePath $executablePath) { + $pidValue = [int]$process.ProcessId + if (-not $ownedByPid.ContainsKey($pidValue)) { + $ownedByPid[$pidValue] = [pscustomobject]@{ + ProcessId = $pidValue + Name = [string]$process.Name + ExecutablePath = Resolve-NormalizedPath $executablePath + Process = $null + } + } + } + } + } catch { + Write-Output "CIM process lookup unavailable; using process-path lookup: $($_.Exception.Message)" + } + + return @($ownedByPid.Values | Sort-Object ProcessId) +} + +function Get-OwnedProcessById { + param([int]$ProcessId) + return @(Get-OwnedProcesses | Where-Object { $_.ProcessId -eq $ProcessId } | Select-Object -First 1) +} + +function Request-GracefulExit { + param($OwnedProcess) + if (-not $OwnedProcess -or $OwnedProcess.Name -eq 'node') { return $false } + try { + $process = if ($OwnedProcess.Process) { + $OwnedProcess.Process + } else { + Get-Process -Id $OwnedProcess.ProcessId -ErrorAction Stop + } + if ($process.MainWindowHandle -ne 0 -and $process.CloseMainWindow()) { + Write-Output "Requested graceful exit for PID $($OwnedProcess.ProcessId)." + return $true + } + } catch { + # Exiting between discovery and notification is a successful race. + } + return $false +} + +function Stop-OwnedProcessTree { + param($OwnedProcess) + if (-not $OwnedProcess) { return } + $stillOwned = @(Get-OwnedProcessById $OwnedProcess.ProcessId) + if ($stillOwned.Count -eq 0) { return } + + $taskkillPath = Join-Path $env:SystemRoot 'System32\taskkill.exe' + try { + $taskkillOutput = & $taskkillPath /PID $OwnedProcess.ProcessId /T /F 2>&1 + $taskkillExitCode = $LASTEXITCODE + if ($taskkillOutput) { + Write-Output (($taskkillOutput | ForEach-Object { [string]$_ }) -join ' ') + } + if ($taskkillExitCode -eq 0) { return } + } catch { + Write-Output "taskkill failed for PID $($OwnedProcess.ProcessId): $($_.Exception.Message)" + } + + # taskkill may report a benign race after the target already exited. Only + # fall back to Stop-Process when the same owned executable still has the PID. + if (@(Get-OwnedProcessById $OwnedProcess.ProcessId).Count -eq 0) { return } + try { + Stop-Process -Id $OwnedProcess.ProcessId -Force -ErrorAction Stop + Write-Output "Force-stopped installed process PID $($OwnedProcess.ProcessId)." + } catch { + if (@(Get-OwnedProcessById $OwnedProcess.ProcessId).Count -gt 0) { + Write-Output "Stop-Process failed for PID $($OwnedProcess.ProcessId): $($_.Exception.Message)" + } + } +} + +function Get-LockedInstalledExecutables { + $locked = @() + foreach ($candidate in $expectedExecutablePaths) { + if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } + $stream = $null + try { + $stream = [System.IO.File]::Open( + $candidate, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::ReadWrite, + [System.IO.FileShare]::None + ) + } catch { + $locked += $candidate + } finally { + if ($stream) { $stream.Dispose() } + } + } + return @($locked) +} + +$deadline = (Get-Date).AddSeconds($TimeoutSeconds) +$gracefulExitAttempted = [System.Collections.Generic.HashSet[int]]::new() + +do { + $ownedProcesses = @(Get-OwnedProcesses) + $lockedExecutables = @(Get-LockedInstalledExecutables) + if ($ownedProcesses.Count -eq 0 -and $lockedExecutables.Count -eq 0) { + Write-Output 'Installed HTTPS Proxy Manager processes stopped and executable locks released.' + exit 0 + } + + $requestedGracefulExit = $false + foreach ($ownedProcess in $ownedProcesses) { + if ($gracefulExitAttempted.Add([int]$ownedProcess.ProcessId)) { + if (Request-GracefulExit $ownedProcess) { $requestedGracefulExit = $true } + } + } + if ($requestedGracefulExit) { Start-Sleep -Milliseconds 750 } + + foreach ($ownedProcess in @(Get-OwnedProcesses)) { + Stop-OwnedProcessTree $ownedProcess + } + Start-Sleep -Milliseconds 150 +} while ((Get-Date) -lt $deadline) + +$remainingProcesses = @(Get-OwnedProcesses) +$remainingLocks = @(Get-LockedInstalledExecutables) +$processDetails = ($remainingProcesses | ForEach-Object { + "PID=$($_.ProcessId) path=$($_.ExecutablePath)" +}) -join '; ' +$lockDetails = $remainingLocks -join '; ' +throw "Cleanup timed out after $TimeoutSeconds seconds. Remaining processes: [$processDetails]. Locked executables: [$lockDetails]." diff --git a/src-tauri/permissions/app-commands.toml b/src-tauri/permissions/app-commands.toml index e6dd8bc8..1369b939 100644 --- a/src-tauri/permissions/app-commands.toml +++ b/src-tauri/permissions/app-commands.toml @@ -18,6 +18,9 @@ allow = [ "selectDirectoryNative", "get_proxy_debug", "getProxyDebug", + "get_cert_status_native", + "get_management_token_native", + "clear_audit_conversations_native", "execute_proxy_command", "executeProxyCommand", "get_launch_on_login", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d5706001..53da7901 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -650,7 +650,7 @@ fn start_proxy_if_needed() { } } -pub fn request_proxy_autostart() { +fn request_proxy_autostart() { if read_app_setting("autoStartProxy", true) { if PROXY_AUTOSTART_IN_FLIGHT.swap(true, Ordering::SeqCst) { return; @@ -1254,8 +1254,6 @@ mod tests { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - request_proxy_autostart(); - tauri::Builder::default() // ── 单例启动 ────────────────────────────────────── .plugin(tauri_plugin_dialog::init()) @@ -1280,13 +1278,9 @@ pub fn run() { let _ = RESOURCE_ROOT.set(resource_dir); } - // Start the proxy near the beginning of setup so release/portable startup - // does not wait for menu and tray initialization before health is available. - if read_app_setting("autoStartProxy", true) { - std::thread::spawn(|| { - start_proxy_if_needed(); - }); - } + // Start once, after RESOURCE_ROOT is available, through the guarded + // autostart entry point shared by every build mode. + request_proxy_autostart(); // 读取配置并设置菜单的初始显示状态 let show_menu = read_app_setting("showTopMenu", true); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 111ae86b..69c3a72e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,6 +2,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - app_lib::request_proxy_autostart(); app_lib::run(); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c00d2db4..9a000a07 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -7,7 +7,7 @@ "frontendDist": "../dist/tauri-ui", "devUrl": "http://localhost:1430", "beforeDevCommand": "node scripts/sync-frontend.js --check && node scripts/dev-static-server.js", - "beforeBuildCommand": "node scripts/sync-frontend.js --check && node scripts/prepare-tauri-dist.js" + "beforeBuildCommand": "node scripts/sync-frontend.js --check && node scripts/prepare-tauri-dist.js && node scripts/prepare-release-runtime.js" }, "app": { "withGlobalTauri": true, @@ -40,18 +40,18 @@ "../proxy.bat": "proxy.bat", "../proxy.ps1": "proxy.ps1", "../proxy.config.example.json": "proxy.config.example.json", - "../package.json": "package.json", + "../dist/release-runtime/package.json": "package.json", "../lib": "lib", - "../node_modules/node-forge/package.json": "node_modules/node-forge/package.json", - "../node_modules/node-forge/LICENSE": "node_modules/node-forge/LICENSE", - "../node_modules/node-forge/lib": "node_modules/node-forge/lib", - "../dist/portable/HTTPS Proxy Manager Portable/runtime": "runtime" + "../dist/release-runtime/node_modules": "node_modules", + "../dist/release-runtime/runtime": "runtime", + "../dist/release-runtime/release-runtime-manifest.json": "release-runtime-manifest.json" }, "windows": { "nsis": { "installMode": "currentUser", "displayLanguageSelector": false, - "languages": ["SimpChinese", "English"] + "languages": ["SimpChinese", "English"], + "installerHooks": "nsis/installer-hooks.nsh" } } } diff --git a/tauri-ui/app-cli.js b/tauri-ui/app-cli.js index eb3a1da8..4cec3bbd 100644 --- a/tauri-ui/app-cli.js +++ b/tauri-ui/app-cli.js @@ -64,10 +64,9 @@ async function initCodeToolsView() { codeToolsInitialized = true; // 1. 从 localStorage 加载配置预设 - let profiles = []; - try { - profiles = JSON.parse(localStorage.getItem('hpm_cli_profiles')) || []; - } catch (e) {} + let profiles = window.UX.storage.parseCliProfiles( + localStorage.getItem('hpm_cli_profiles') || '[]' + ); if (!Array.isArray(profiles) || profiles.length === 0) { profiles = [ @@ -99,6 +98,7 @@ async function initCodeToolsView() { clearTimeout(cliProfilesSaveTimer); cliProfilesSaveTimer = null; } + profiles = window.UX.storage.normalizeCliProfiles(profiles); localStorage.setItem('hpm_cli_profiles', JSON.stringify(profiles)); } @@ -106,6 +106,7 @@ async function initCodeToolsView() { if (cliProfilesSaveTimer) clearTimeout(cliProfilesSaveTimer); cliProfilesSaveTimer = setTimeout(() => { cliProfilesSaveTimer = null; + profiles = window.UX.storage.normalizeCliProfiles(profiles); localStorage.setItem('hpm_cli_profiles', JSON.stringify(profiles)); }, 300); } diff --git a/tauri-ui/app-ux.js b/tauri-ui/app-ux.js index 9c6ea572..3a51279d 100644 --- a/tauri-ui/app-ux.js +++ b/tauri-ui/app-ux.js @@ -157,6 +157,76 @@ return currentThemeMode; } + // ─── localStorage 边界 ───────────────────────────────────────── + const MAX_STORED_JSON_CHARS = 5 * 1024 * 1024; + const MAX_PROMPT_TEMPLATES = 100; + const MAX_PROMPT_NAME_CHARS = 128; + const MAX_PROMPT_CONTENT_CHARS = 200000; + const MAX_CLI_PROFILES = 50; + + function parseStoredArray(raw) { + if (typeof raw !== 'string' || raw.length === 0 || raw.length > MAX_STORED_JSON_CHARS) + return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch (_err) { + return []; + } + } + + function boundedString(value, maxChars, fallback = '') { + if (typeof value !== 'string') return fallback; + return value.slice(0, maxChars); + } + + function normalizePromptTemplates(value) { + if (!Array.isArray(value)) return []; + return value.slice(0, MAX_PROMPT_TEMPLATES).flatMap((item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return []; + const name = boundedString(item.name, MAX_PROMPT_NAME_CHARS).trim(); + const content = boundedString(item.content, MAX_PROMPT_CONTENT_CHARS); + if (!name || !content.trim()) return []; + return [{ name, content }]; + }); + } + + function parsePromptTemplates(raw) { + return normalizePromptTemplates(parseStoredArray(raw)); + } + + function normalizeCliProfiles(value) { + if (!Array.isArray(value)) return []; + const ids = new Set(); + const profiles = []; + for (const item of value.slice(0, MAX_CLI_PROFILES)) { + if (!item || typeof item !== 'object' || Array.isArray(item)) continue; + const id = boundedString(item.id, 128).trim(); + const toolId = boundedString(item.toolId, 64, 'claude-code').trim(); + if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(id) || !/^[A-Za-z0-9_.:-]{1,64}$/.test(toolId)) { + continue; + } + if (ids.has(id)) continue; + ids.add(id); + profiles.push({ + id, + name: boundedString(item.name, 128, '未命名预设').trim() || '未命名预设', + toolId, + model: boundedString(item.model, 256, 'default').trim() || 'default', + customArgs: boundedString(item.customArgs, 4096), + cwd: boundedString(item.cwd, 2048), + envs: boundedString(item.envs, 16384), + terminal: boundedString(item.terminal, 64, 'PowerShell').trim() || 'PowerShell', + upgrade: item.upgrade === true, + }); + } + return profiles; + } + + function parseCliProfiles(raw) { + return normalizeCliProfiles(parseStoredArray(raw)); + } + // ─── 延迟加载:IntersectionObserver 包装 ───────────────────────── // - 元素进入视口时调用 loader(),执行后取消观察。 // - 若浏览器不支持 IntersectionObserver,则在 requestIdleCallback 中 @@ -262,5 +332,15 @@ lazyOnVisible, loadScript, whenIdle, + storage: { + parsePromptTemplates, + normalizePromptTemplates, + parseCliProfiles, + normalizeCliProfiles, + limits: { + promptNameChars: MAX_PROMPT_NAME_CHARS, + promptContentChars: MAX_PROMPT_CONTENT_CHARS, + }, + }, }; })(); diff --git a/tauri-ui/app.js b/tauri-ui/app.js index 0f2dd283..7ff1ce67 100644 --- a/tauri-ui/app.js +++ b/tauri-ui/app.js @@ -6512,6 +6512,10 @@ function loadPresetPrompts() { }); } +function readStoredPromptTemplates() { + return window.UX.storage.parsePromptTemplates(localStorage.getItem('prompt_templates') || '[]'); +} + function loadTemplates() { const listContainer = $('templateList'); if (!listContainer) return; @@ -6523,10 +6527,7 @@ function loadTemplates() { const item = event.target.closest('[data-template-index]'); if (!item || !listContainer.contains(item)) return; const index = Number(item.dataset.templateIndex); - let templates = []; - try { - templates = JSON.parse(localStorage.getItem('prompt_templates') || '[]'); - } catch (e) {} + const templates = readStoredPromptTemplates(); const tpl = templates[index]; if (!tpl) return; if (deleteButton && listContainer.contains(deleteButton)) { @@ -6545,10 +6546,7 @@ function loadTemplates() { }; } - let templates = []; - try { - templates = JSON.parse(localStorage.getItem('prompt_templates') || '[]'); - } catch (e) {} + const templates = readStoredPromptTemplates(); if (templates.length === 0) { listContainer.appendChild(createTemplateEmptyElement()); @@ -6607,11 +6605,17 @@ if (saveTplBtn) { showToast('提示词内容不能为空', 'error'); return; } + const storageLimits = window.UX.storage.limits; + if (name.length > storageLimits.promptNameChars) { + showToast(`模板名称不能超过 ${storageLimits.promptNameChars} 个字符`, 'error'); + return; + } + if (content.length > storageLimits.promptContentChars) { + showToast(`模板内容不能超过 ${storageLimits.promptContentChars} 个字符`, 'error'); + return; + } - let templates = []; - try { - templates = JSON.parse(localStorage.getItem('prompt_templates') || '[]'); - } catch (e) {} + const templates = readStoredPromptTemplates(); const existingIdx = templates.findIndex((t) => t.name === name); if (existingIdx >= 0) { diff --git a/tauri-ui/src/package.json b/tauri-ui/src/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/tauri-ui/src/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/test/app-cli.test.js b/test/app-cli.test.js index be56a716..83c6c48f 100644 --- a/test/app-cli.test.js +++ b/test/app-cli.test.js @@ -35,5 +35,13 @@ assert.ok( source.includes('setCliLaunchButtonReady()'), 'CLI launch button should reset through DOM helper' ); +assert.ok( + source.includes('window.UX.storage.parseCliProfiles('), + 'CLI profiles should pass through the shared localStorage boundary parser' +); +assert.ok( + source.includes('window.UX.storage.normalizeCliProfiles(profiles)'), + 'CLI profiles should be normalized again before persistence' +); console.log('app-cli.test.js ok'); diff --git a/test/app-ux.test.js b/test/app-ux.test.js index da3b4e72..b68be42a 100644 --- a/test/app-ux.test.js +++ b/test/app-ux.test.js @@ -255,6 +255,39 @@ function tick(ms = 0) { } // ─── 7. Claude Desktop process verification controls exist ──── + { + const { sandbox } = buildSandbox(); + const UX = loadUx(sandbox); + assert.deepEqual(Array.from(UX.storage.parsePromptTemplates('invalid')), []); + assert.deepEqual(Array.from(UX.storage.parsePromptTemplates('{"name":"not-array"}')), []); + assert.deepEqual( + JSON.parse( + JSON.stringify( + UX.storage.parsePromptTemplates( + JSON.stringify([ + { name: ' valid ', content: 'content' }, + { name: '', content: 'drop' }, + { name: 'wrong', content: 42 }, + ]) + ) + ) + ), + [{ name: 'valid', content: 'content' }] + ); + const cliProfiles = UX.storage.parseCliProfiles( + JSON.stringify([ + { id: 'valid', name: 'CLI', toolId: 'codex', model: 'm', upgrade: true }, + { id: 'valid', name: 'duplicate', toolId: 'codex' }, + { id: 'bad id', toolId: 'codex' }, + ]) + ); + assert.equal(cliProfiles.length, 1); + assert.equal(cliProfiles[0].id, 'valid'); + assert.equal(cliProfiles[0].upgrade, true); + assert.equal(UX.storage.parsePromptTemplates('x'.repeat(5 * 1024 * 1024 + 1)).length, 0); + } + + // ─── 8. Claude Desktop process verification controls exist ──── { const html = fs.readFileSync( path.join(__dirname, '..', 'tauri-ui', 'electron-ui.html'), @@ -755,8 +788,10 @@ function tick(ms = 0) { 'utf8' ); assert.ok( - portableScript.includes("(Join-Path $PortableDir 'proxy.config.json')"), - 'portable packages include an explicit proxy.config.json on first build' + !portableScript.includes("(Join-Path $PortableDir 'proxy.config.json')") && + portableScript.includes("'proxy.config.example.json'") && + portableScript.includes('scripts/validate-release-package.js'), + 'portable packages are stateless and validated before distribution' ); } diff --git a/test/audit.test.js b/test/audit.test.js index 419e51a4..db9ee41e 100644 --- a/test/audit.test.js +++ b/test/audit.test.js @@ -12,6 +12,16 @@ const { _test } = require('../lib/proxy/audit'); }); assert.ok(compacted.text.endsWith('...[truncated]')); assert.strictEqual(compacted.nested.keep, ''); + assert.deepStrictEqual( + _test.compactAuditValue({ + authorization: 'Bearer secret', + nested: { apiKey: 'sk-secret', 'x-api-key': 'x-secret', safe: 'visible' }, + }), + { + authorization: '[redacted]', + nested: { apiKey: '[redacted]', 'x-api-key': '[redacted]', safe: 'visible' }, + } + ); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hpm-audit-jsonl-')); try { diff --git a/test/builtin-model-routes.test.js b/test/builtin-model-routes.test.js new file mode 100644 index 00000000..6530332b --- /dev/null +++ b/test/builtin-model-routes.test.js @@ -0,0 +1,49 @@ +'use strict'; + +const assert = require('assert'); +const { + BUILTIN_MODEL_ROUTES, + mergeBuiltinModelRoutes, +} = require('../lib/proxy/builtin-model-routes'); + +const source = { + profiles: [{ id: 'openai-official' }, { id: 'google-official' }], + modelRoutes: [ + { from: 'gpt-5.6-sol', profileId: 'custom-openai', to: 'custom-sol' }, + { from: 'existing', profileId: 'openai-official', to: 'gpt-5.5' }, + ], +}; +const merged = mergeBuiltinModelRoutes(source); + +assert.notStrictEqual(merged, source, 'merge must return a new config object'); +assert.notStrictEqual(merged.modelRoutes, source.modelRoutes, 'merge must not mutate route arrays'); +assert.strictEqual(source.modelRoutes.length, 2, 'source routes must remain unchanged'); +assert.deepStrictEqual( + merged.modelRoutes.find((route) => route.from === 'gpt-5.6-sol'), + source.modelRoutes[0], + 'an explicit user route must override the built-in route' +); +assert.deepStrictEqual( + merged.modelRoutes.find((route) => route.from === 'gpt-5.6-terra'), + { from: 'gpt-5.6-terra', profileId: 'openai-official', to: 'gpt-5.6-terra' } +); +assert.deepStrictEqual( + merged.modelRoutes.find((route) => route.from === 'Gemini 3.6 Flash (High)'), + { + from: 'Gemini 3.6 Flash (High)', + profileId: 'google-official', + to: 'gemini-3.6-flash', + } +); +assert.strictEqual( + mergeBuiltinModelRoutes(merged).modelRoutes.length, + merged.modelRoutes.length, + 'merge must be idempotent' +); + +const missingProfiles = mergeBuiltinModelRoutes({ profiles: [], modelRoutes: [] }); +assert.deepStrictEqual(missingProfiles.modelRoutes, []); +assert.strictEqual(mergeBuiltinModelRoutes(null), null); +assert.ok(BUILTIN_MODEL_ROUTES.length >= 14); + +console.log('[builtin-model-routes.test] ok'); diff --git a/test/claude-self-test-target.test.js b/test/claude-self-test-target.test.js new file mode 100644 index 00000000..b53ff9b8 --- /dev/null +++ b/test/claude-self-test-target.test.js @@ -0,0 +1,33 @@ +const assert = require('assert'); +const { + appendClaudeMessagesPath, + createClaudeSelfTestTargetResolver, +} = require('../lib/proxy/claude-self-test-target'); + +assert.strictEqual( + appendClaudeMessagesPath('https://localhost:3443/v1/'), + 'https://localhost:3443/v1/messages' +); +assert.throws(() => createClaudeSelfTestTargetResolver(), /requires runtime getters/); + +const resolveTarget = createClaudeSelfTestTargetResolver({ + claudeDesktopBaseUrl: () => 'https://fallback.test', + claudeDesktopProxyPrefix: '/claude-desktop', + getClaudeDesktopGatewayConfig: () => ({ + isLocalProxy: true, + baseUrl: 'https://localhost:3443/v1', + token: 'test-token', + }), + getClaudeDesktopRoutes: () => [{ from: 'claude-test', profileId: 'profile-1' }], + getClaudeDesktopTraeProcessStyle: () => 'thinking', + getConfig: () => ({}), + getProfiles: () => [{ id: 'profile-1', protocol: 'claude', targetUrl: 'https://upstream.test' }], + isTraeOfficialProfile: () => false, + isTraeOfficialTarget: () => false, +}); +const target = resolveTarget(); +assert.strictEqual(target.endpoint, 'https://localhost:3443/v1/messages'); +assert.strictEqual(target.headers.Authorization, 'Bearer test-token'); +assert.strictEqual(target.model, 'claude-test'); + +console.log('[claude-self-test-target.test] ok'); diff --git a/test/config-watcher.test.js b/test/config-watcher.test.js index 9d61a750..38fc2fac 100644 --- a/test/config-watcher.test.js +++ b/test/config-watcher.test.js @@ -198,6 +198,30 @@ function rmrf() { watcher8.close(); console.log('[config-watcher.test] loadConfigFn normalization ok'); + // 9. Invalid normalized config must not replace the last valid runtime config. + writeJson({ activeProfile: 'invalid' }); + let invalidApplied = 0; + let invalidErrors = 0; + const watcher9 = debounceReload( + CFG_FILE, + () => { + invalidApplied += 1; + }, + { + loadConfigFn: () => { + throw new Error('Invalid proxy configuration'); + }, + onError: () => { + invalidErrors += 1; + }, + } + ); + watcher9.scheduleNow(); + await waitFor(() => invalidErrors === 1, 500); + assert.strictEqual(invalidApplied, 0); + watcher9.close(); + console.log('[config-watcher.test] invalid normalized config rejected'); + rmrf(); console.log('[config-watcher.test] ok'); process.exit(0); diff --git a/test/conversation-audit-utils.test.js b/test/conversation-audit-utils.test.js index 191731be..42e5c6ec 100644 --- a/test/conversation-audit-utils.test.js +++ b/test/conversation-audit-utils.test.js @@ -45,9 +45,34 @@ function createAuditCaptureDeps(records, usageLogs) { appendAuditRecord: (record) => records.push(record), config: {}, log: { error: (message) => records.push({ error: message }) }, + captureContent: true, }; } +{ + const auditData = createConversationAuditData({ + id: 'metadata-only', + captureContent: false, + }); + const auditRes = { __auditData: auditData }; + populateAuditDataFromPrepared( + auditRes, + { + profile: { id: 'p1', protocol: 'openai' }, + model: 'm1', + buffer: Buffer.from(JSON.stringify({ messages: [{ role: 'user', content: 'private' }] })), + }, + Buffer.alloc(0), + { + isGeminiProfile: () => false, + isClaudeProfile: () => false, + } + ); + assert.deepStrictEqual(auditData.messages, []); + assert.strictEqual(auditData.system, ''); + assert.ok(auditData.request.bodyBytes > 0); +} + { const auditData = createConversationAuditData({ id: 'req-1', diff --git a/test/core-request-handler.test.js b/test/core-request-handler.test.js new file mode 100644 index 00000000..97acd2b7 --- /dev/null +++ b/test/core-request-handler.test.js @@ -0,0 +1,10 @@ +const assert = require('assert'); +const { createCoreRequestHandler } = require('../lib/proxy/core-request-handler'); + +assert.throws(() => createCoreRequestHandler(), /requires getRuntimeState/); + +const handler = createCoreRequestHandler({ getRuntimeState: () => ({}) }); +assert.strictEqual(typeof handler, 'function'); +assert.strictEqual(handler.name, 'handleRequestInner'); + +console.log('[core-request-handler.test] ok'); diff --git a/test/entry-auth.test.js b/test/entry-auth.test.js index 6fba9938..a2a1fcb4 100644 --- a/test/entry-auth.test.js +++ b/test/entry-auth.test.js @@ -7,7 +7,11 @@ const { profileMatchesEntryKey, stripEntryAuthHeaders, } = require('../lib/proxy/entry-auth'); -const { decryptKey, encryptConfigSecretsForStorage } = require('../lib/proxy/config'); +const { + decryptConfigSecretsInPlace, + decryptKey, + encryptConfigSecretsForStorage, +} = require('../lib/proxy/config'); const entry = { protocol: 'openai-chat', listenPort: 3447 }; @@ -121,6 +125,7 @@ const entry = { protocol: 'openai-chat', listenPort: 3447 }; }); assert.notStrictEqual(stored.entryAuth.keys[0].key, 'entry-secret'); assert.strictEqual(decryptKey(stored.entryAuth.keys[0].key), 'entry-secret'); + assert.strictEqual(decryptConfigSecretsInPlace(stored).entryAuth.keys[0].key, 'entry-secret'); } console.log('[entry-auth.test] ok'); diff --git a/test/gateway-ca-trust.test.js b/test/gateway-ca-trust.test.js index a13593b7..77c7bdbe 100644 --- a/test/gateway-ca-trust.test.js +++ b/test/gateway-ca-trust.test.js @@ -97,6 +97,29 @@ const cli = require('../scripts/install-gateway-ca'); cli.defaultRuntimeDir({ HTTPS_PROXY_RUNTIME_DIR: temp }, 'linux'), path.resolve(temp) ); + + const workspace = path.join(temp, 'workspace'); + const workspaceRuntime = path.join(workspace, '.hpm-runtime'); + const localAppData = path.join(temp, 'local-app-data'); + const installedRuntime = path.join(localAppData, 'HTTPS Proxy Manager', '.hpm-runtime'); + fs.mkdirSync(workspaceRuntime, { recursive: true }); + fs.mkdirSync(installedRuntime, { recursive: true }); + assert.strictEqual( + cli.defaultRuntimeDir({ LOCALAPPDATA: localAppData }, 'win32', workspace), + workspaceRuntime, + 'a source checkout must use its own active runtime before an unrelated installed runtime' + ); + + const explicitRuntime = path.join(temp, 'explicit-runtime'); + assert.strictEqual( + cli.defaultRuntimeDir( + { HTTPS_PROXY_RUNTIME_DIR: explicitRuntime, LOCALAPPDATA: localAppData }, + 'win32', + workspace + ), + path.resolve(explicitRuntime), + 'an explicit runtime remains the highest-priority source' + ); } finally { fs.rmSync(temp, { recursive: true, force: true }); } diff --git a/test/gemini-adapter.test.js b/test/gemini-adapter.test.js index 5aa3118d..c13ac191 100644 --- a/test/gemini-adapter.test.js +++ b/test/gemini-adapter.test.js @@ -48,6 +48,30 @@ test('converts Claude text request to Gemini generateContent body', () => { ]); }); +test('removes deprecated sampling parameters for Gemini 3.6+', () => { + const body = { + max_tokens: 256, + temperature: 0.2, + top_p: 0.9, + top_k: 20, + messages: [{ role: 'user', content: 'hello' }], + }; + + const current = claudeMessagesToGemini(body, 'gemini-3.6-flash'); + assert.deepStrictEqual(current.generationConfig, { maxOutputTokens: 256 }); + + const future = claudeMessagesToGemini(body, 'models/gemini-4.0-flash'); + assert.deepStrictEqual(future.generationConfig, { maxOutputTokens: 256 }); + + const legacy = claudeMessagesToGemini(body, 'gemini-3.5-flash'); + assert.deepStrictEqual(legacy.generationConfig, { + maxOutputTokens: 256, + temperature: 0.2, + topP: 0.9, + topK: 20, + }); +}); + test('converts images and tool declarations', () => { const result = claudeMessagesToGemini( { diff --git a/test/google-official.test.js b/test/google-official.test.js index e5a8caed..d272e1f7 100644 --- a/test/google-official.test.js +++ b/test/google-official.test.js @@ -13,6 +13,7 @@ const { decodeAntigravityOAuthStateValue, } = require('../lib/proxy/google-official'); const { createGoogleOfficialRuntime } = require('../lib/proxy/google-official-runtime'); +const { createCodeAssist } = require('../lib/proxy/handlers/code-assist'); function toBase64Url(value) { return Buffer.from(value) @@ -27,6 +28,10 @@ async function main() { assert.strictEqual(response.ok, true); assert.strictEqual(response.authMode, 'official-login'); assert.strictEqual(response.source, 'official-model-config'); + assert.ok(response.models.includes('Gemini 3.6 Flash (Medium)')); + assert.ok(response.models.includes('Gemini 3.6 Flash (High)')); + assert.ok(response.models.includes('Gemini 3.6 Flash (Low)')); + assert.ok(response.models.includes('gemini-3.6-flash')); assert.ok(response.models.includes('Gemini 3.5 Flash (Medium)')); assert.ok(response.models.includes('gemini-3.5-flash-low')); assert.ok(response.models.includes('gemini-3-flash-agent')); @@ -46,6 +51,18 @@ async function main() { assert.ok(models.includes('gemini-3-flash-agent')); assert.ok(models.includes('gemini-3.5-flash-extra-low')); + const codeAssist = createCodeAssist({ fallbackId: () => 'test-id' }); + for (const name of [ + 'Gemini 3.6 Flash (Medium)', + 'Gemini 3.6 Flash (High)', + 'Gemini 3.6 Flash (Low)', + 'gemini-3.6-flash-medium', + 'gemini-3.6-flash-high', + 'gemini-3.6-flash-low', + ]) { + assert.strictEqual(codeAssist.codeAssistModelSelection(name).model, 'gemini-3.6-flash'); + } + const inner = toBase64Url( 'token ya29.test-access Bearer refresh 1//test-refresh ' + 'x'.repeat(96) ); diff --git a/test/headers-routing.test.js b/test/headers-routing.test.js index 6cca900d..3b2e99e7 100644 --- a/test/headers-routing.test.js +++ b/test/headers-routing.test.js @@ -2,8 +2,37 @@ const assert = require('assert'); const http = require('http'); const { spawn } = require('child_process'); const fs = require('fs'); +const os = require('os'); const path = require('path'); +const testHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'https-proxy-headers-routing-')); +const testAppDataDir = path.join(testHomeDir, 'AppData', 'Roaming'); + +fs.mkdirSync(path.join(testHomeDir, '.codex'), { recursive: true }); +fs.writeFileSync( + path.join(testHomeDir, '.codex', 'auth.json'), + JSON.stringify({ tokens: { access_token: 'eyJ.test-openai-oauth-token' } }) +); +fs.mkdirSync(path.join(testHomeDir, '.gemini'), { recursive: true }); +fs.writeFileSync( + path.join(testHomeDir, '.gemini', 'oauth_creds.json'), + JSON.stringify({ access_token: 'ya29.test-google-oauth-token' }) +); + +function createProxyEnv() { + return { + ...process.env, + APPDATA: testAppDataDir, + CODE_ASSIST_ENDPOINT: `http://127.0.0.1:${upstreamPort}`, + GOOGLE_CLOUD_PROJECT: 'test-project', + HOME: testHomeDir, + LOCALAPPDATA: path.join(testHomeDir, 'AppData', 'Local'), + USERPROFILE: testHomeDir, + MGMT_PORT: String(managementPort), + PROXY_DATA_DIR: __dirname, + }; +} + // 1. 创建一个 Mock 下游服务器 (Upstream) let receivedHeaders = {}; const mockUpstream = http.createServer((req, res) => { @@ -13,9 +42,33 @@ const mockUpstream = http.createServer((req, res) => { }); let upstreamPort = 0; -mockUpstream.listen(0, '127.0.0.1', () => { +let claudeEntryPort = 0; +let openAiEntryPort = 0; +let managementPort = 0; + +function findAvailablePort() { + return new Promise((resolve, reject) => { + const server = http.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + server.close((error) => (error ? reject(error) : resolve(port))); + }); + }); +} + +mockUpstream.listen(0, '127.0.0.1', async () => { upstreamPort = mockUpstream.address().port; - runTests(); + try { + claudeEntryPort = await findAvailablePort(); + openAiEntryPort = await findAvailablePort(); + managementPort = await findAvailablePort(); + await runTests(); + } catch (error) { + console.error('\nTest failed:', error); + process.exitCode = 1; + cleanUp(); + } }); // 2. 准备测试配置文件 @@ -28,7 +81,7 @@ function setupTestConfig(profile) { basePath: '/v1', enabled: true, host: '127.0.0.1', - listenPort: 4443, + listenPort: claudeEntryPort, name: 'Test Claude Entry', protocol: 'claude', tls: false, @@ -37,7 +90,7 @@ function setupTestConfig(profile) { basePath: '/v1', enabled: true, host: '127.0.0.1', - listenPort: 4445, + listenPort: openAiEntryPort, name: 'Test OpenAI Entry', protocol: 'openai-chat', tls: false, @@ -46,7 +99,8 @@ function setupTestConfig(profile) { localServer: { basePath: '/v1', host: '127.0.0.1', - listenPort: 4443, + listenPort: claudeEntryPort, + managementPort, protocol: 'claude', }, profiles: [profile], @@ -57,7 +111,7 @@ function setupTestConfig(profile) { function readManagementToken() { const candidates = [ - path.join(__dirname, '.mgmt-token-4543'), + path.join(__dirname, `.mgmt-token-${managementPort}`), path.join(__dirname, '.mgmt-token'), ]; for (const file of candidates) { @@ -100,7 +154,7 @@ function sendClaudeMessageRequest() { const req = http.request( { hostname: '127.0.0.1', - port: 4443, + port: claudeEntryPort, path: '/v1/messages', method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -135,6 +189,7 @@ async function waitForForwardedApiKey(expectedKey, { timeoutMs = 5000, intervalM function cleanUp() { if (fs.existsSync(configPath)) fs.unlinkSync(configPath); + fs.rmSync(testHomeDir, { recursive: true, force: true }); mockUpstream.close(); } @@ -235,7 +290,7 @@ async function runTests() { authorization: 'Bearer client-openai-oauth-token', 'api-key': 'client-api-key', }, - clientPort: 4445, + clientPort: openAiEntryPort, clientPath: '/v1/chat/completions', clientBody: { model: 'gpt-4', messages: [{ role: 'user', content: 'hi' }] }, expectedHeadersCheck: (headers) => { @@ -257,7 +312,7 @@ async function runTests() { authorization: 'Bearer client-openai-oauth-token', 'api-key': 'client-api-key', }, - clientPort: 4445, + clientPort: openAiEntryPort, clientPath: '/v1/chat/completions', clientBody: { model: 'gpt-4', messages: [{ role: 'user', content: 'hi' }] }, expectedHeadersCheck: (headers) => { @@ -280,7 +335,7 @@ async function runTests() { authorization: 'Bearer client-google-token', 'x-goog-api-key': 'client-google-key', }, - clientPort: 4445, + clientPort: openAiEntryPort, clientPath: '/v1/chat/completions', clientBody: { model: 'gpt-4', messages: [{ role: 'user', content: 'hi' }] }, expectedHeadersCheck: (headers) => { @@ -314,7 +369,7 @@ function runSingleTest({ clientHeaders, expectedHeadersCheck, message, - clientPort = 4443, + clientPort = claudeEntryPort, clientPath = '/v1/messages', clientBody, }) { @@ -324,10 +379,7 @@ function runSingleTest({ // 启动代理子进程,通过环境变量加载测试配置 const proxyProc = spawn('node', [path.join(__dirname, '../openai-proxy.js')], { - env: { - ...process.env, - PROXY_DATA_DIR: __dirname, - }, + env: createProxyEnv(), }); let isStarted = false; @@ -423,10 +475,7 @@ function runHotReloadTest() { // 启动代理 const proxyProc = spawn('node', [path.join(__dirname, '../openai-proxy.js')], { - env: { - ...process.env, - PROXY_DATA_DIR: __dirname, - }, + env: createProxyEnv(), }); let isStarted = false; @@ -450,7 +499,7 @@ function runHotReloadTest() { const req1 = http.request( { hostname: '127.0.0.1', - port: 4443, + port: claudeEntryPort, path: '/v1/messages', method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -496,10 +545,7 @@ function runLatencyApiTest() { }); const proxyProc = spawn('node', [path.join(__dirname, '../openai-proxy.js')], { - env: { - ...process.env, - PROXY_DATA_DIR: __dirname, - }, + env: createProxyEnv(), }); let isStarted = false; @@ -519,7 +565,7 @@ function runLatencyApiTest() { const req = http.request( { hostname: '127.0.0.1', - port: 4443, + port: claudeEntryPort, path: '/v1/local/profile/latency', method: 'POST', headers: { 'Content-Type': 'application/json', 'x-mgmt-token': managementToken }, @@ -566,10 +612,7 @@ function runPromptsSyncApiTest() { }); const proxyProc = spawn('node', [path.join(__dirname, '../openai-proxy.js')], { - env: { - ...process.env, - PROXY_DATA_DIR: __dirname, - }, + env: createProxyEnv(), }); let isStarted = false; @@ -589,7 +632,7 @@ function runPromptsSyncApiTest() { const reqClaude = http.request( { hostname: '127.0.0.1', - port: 4443, + port: claudeEntryPort, path: '/v1/local/prompts/sync', method: 'POST', headers: { 'Content-Type': 'application/json', 'x-mgmt-token': managementToken }, @@ -612,7 +655,7 @@ function runPromptsSyncApiTest() { const reqAnti = http.request( { hostname: '127.0.0.1', - port: 4443, + port: claudeEntryPort, path: '/v1/local/prompts/sync', method: 'POST', headers: { diff --git a/test/installer-process-cleanup.test.js b/test/installer-process-cleanup.test.js new file mode 100644 index 00000000..29569c64 --- /dev/null +++ b/test/installer-process-cleanup.test.js @@ -0,0 +1,153 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn, spawnSync } = require('child_process'); + +const repoRoot = path.resolve(__dirname, '..'); +const cleanupScript = path.join(repoRoot, 'src-tauri', 'nsis', 'stop-installed-processes.ps1'); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hpm-installer-cleanup-')); +const installDir = path.join(tempRoot, 'HTTPS Proxy Manager'); +const controlDir = path.join(tempRoot, 'control'); +const bundledNode = path.join(installDir, 'runtime', 'node', 'node.exe'); +const installedApp = path.join(installDir, 'app.exe'); +const legacyInstalledApp = path.join(installDir, 'https-proxy-manager.exe'); +const proxyScript = path.join(installDir, 'openai-proxy.js'); +const controlScript = path.join(controlDir, 'openai-proxy.js'); +const controlApp = path.join(controlDir, 'app.exe'); + +function runPowerShell(args, env = process.env, cwd = repoRoot) { + return spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', ...args], { + cwd, + encoding: 'utf8', + env, + timeout: 35000, + windowsHide: true, + }); +} + +function runCleanup(installPath, fallbackInstallPath = '') { + const args = ['-ExecutionPolicy', 'Bypass', '-File', cleanupScript, '-InstallDir', installPath]; + if (fallbackInstallPath) args.push('-FallbackInstallDir', fallbackInstallPath); + return runPowerShell(args); +} + +function runCleanupFromLocalAppData(localAppData, cwd) { + return runPowerShell( + ['-ExecutionPolicy', 'Bypass', '-File', cleanupScript], + { + ...process.env, + LOCALAPPDATA: localAppData, + }, + cwd + ); +} + +function waitForExit(child, timeoutMs) { + if (child.exitCode !== null) return Promise.resolve(true); + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(false), timeoutMs); + child.once('exit', () => { + clearTimeout(timeout); + resolve(true); + }); + }); +} + +async function main() { + fs.mkdirSync(path.dirname(bundledNode), { recursive: true }); + fs.mkdirSync(controlDir, { recursive: true }); + fs.copyFileSync(process.execPath, bundledNode); + fs.copyFileSync(process.execPath, installedApp); + fs.copyFileSync(process.execPath, legacyInstalledApp); + fs.copyFileSync(process.execPath, controlApp); + fs.writeFileSync(proxyScript, 'setInterval(() => {}, 1000);\n'); + fs.writeFileSync(controlScript, 'setInterval(() => {}, 1000);\n'); + + const emptyResult = runCleanup(path.join(tempRoot, 'missing-install')); + assert.strictEqual(emptyResult.status, 0, emptyResult.stderr || emptyResult.stdout); + + const unsafeResult = runCleanup(path.parse(installDir).root); + assert.notStrictEqual(unsafeResult.status, 0, 'filesystem root must be rejected'); + + const fallbackResult = runCleanup(path.parse(installDir).root, installDir); + assert.strictEqual(fallbackResult.status, 0, fallbackResult.stderr || fallbackResult.stdout); + assert.match(fallbackResult.stdout, /using validated fallback/i); + + const target = spawn(bundledNode, [proxyScript], { stdio: 'ignore', windowsHide: true }); + const appTarget = spawn(installedApp, [proxyScript], { stdio: 'ignore', windowsHide: true }); + const legacyAppTarget = spawn(legacyInstalledApp, [proxyScript], { + stdio: 'ignore', + windowsHide: true, + }); + const control = spawn(process.execPath, [controlScript], { + stdio: 'ignore', + windowsHide: true, + }); + const appControl = spawn(controlApp, [controlScript], { + stdio: 'ignore', + windowsHide: true, + }); + + try { + await new Promise((resolve) => setTimeout(resolve, 250)); + assert.strictEqual(target.exitCode, null, 'owned proxy fixture must be running'); + assert.strictEqual(appTarget.exitCode, null, 'owned desktop fixture must be running'); + assert.strictEqual( + legacyAppTarget.exitCode, + null, + 'owned legacy desktop fixture must be running' + ); + assert.strictEqual(control.exitCode, null, 'control Node fixture must be running'); + assert.strictEqual( + appControl.exitCode, + null, + 'same-name app outside install dir must be running' + ); + + const result = runCleanupFromLocalAppData(tempRoot, installDir); + assert.strictEqual(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Process cleanup install directory:/i); + assert.strictEqual(await waitForExit(target, 3000), true, 'owned proxy must be stopped'); + assert.strictEqual( + await waitForExit(appTarget, 3000), + true, + 'owned desktop app must be stopped' + ); + assert.strictEqual( + await waitForExit(legacyAppTarget, 3000), + true, + 'owned legacy desktop app must be stopped' + ); + assert.strictEqual(control.exitCode, null, 'unrelated Node process must remain running'); + assert.strictEqual( + appControl.exitCode, + null, + 'same-name app outside install dir must remain running' + ); + assert.match(result.stdout, /executable locks released/i); + + console.log('[installer-process-cleanup.test] ok'); + } finally { + if (target.exitCode === null) target.kill(); + if (appTarget.exitCode === null) appTarget.kill(); + if (legacyAppTarget.exitCode === null) legacyAppTarget.kill(); + if (control.exitCode === null) control.kill(); + if (appControl.exitCode === null) appControl.kill(); + await Promise.all([ + waitForExit(target, 3000), + waitForExit(appTarget, 3000), + waitForExit(legacyAppTarget, 3000), + waitForExit(control, 3000), + waitForExit(appControl, 3000), + ]); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/test/logger.test.js b/test/logger.test.js index 581db403..2ff948b5 100644 --- a/test/logger.test.js +++ b/test/logger.test.js @@ -7,6 +7,9 @@ const { setLevel, getLevel, serializeError, + sanitizeLogText, + sanitizeLogValue, + normalizeRequestId, LEVELS, _als, } = require('../lib/proxy/logger'); @@ -50,7 +53,7 @@ console.log('[logger.test] withRequest scoping ok'); // 5. withRequest async propagation async function asyncCheck() { - await new Promise(r => setImmediate(r)); + await new Promise((r) => setImmediate(r)); return getReqId(); } (async () => { @@ -73,7 +76,27 @@ async function asyncCheck() { assert.strictEqual(serializeError(undefined), null); console.log('[logger.test] serializeError string/null ok'); - // 8. setLevel / getLevel + // 8. secret redaction and request-id boundary + assert.strictEqual( + sanitizeLogText('Authorization: Bearer super-secret'), + 'Authorization: Bearer [redacted]' + ); + assert.strictEqual( + sanitizeLogText('https://user:pass@example.test/v1?apiKey=secret&safe=1'), + 'https://[redacted]@example.test/v1?apiKey=[redacted]&safe=1' + ); + assert.deepStrictEqual( + sanitizeLogValue({ authorization: 'Bearer secret', nested: { apiKey: 'sk-test', safe: 1 } }), + { authorization: '[redacted]', nested: { apiKey: '[redacted]', safe: 1 } } + ); + const serializedSecretError = serializeError(new Error('request failed: Bearer secret-token')); + assert.ok(!serializedSecretError.message.includes('secret-token')); + assert.strictEqual(normalizeRequestId('safe-id:123'), 'safe-id:123'); + assert.match(normalizeRequestId('bad\nrequest-id'), /^[a-f0-9]{12}$/); + assert.match(normalizeRequestId('x'.repeat(129)), /^[a-f0-9]{12}$/); + console.log('[logger.test] redaction/request-id boundary ok'); + + // 9. setLevel / getLevel const before = getLevel(); setLevel('warn'); assert.strictEqual(getLevel(), 'warn'); @@ -83,12 +106,12 @@ async function asyncCheck() { setLevel(before); console.log('[logger.test] setLevel/getLevel ok'); - // 9. LEVELS export + // 10. LEVELS export assert.ok(LEVELS.INFO < LEVELS.WARN); assert.ok(LEVELS.WARN < LEVELS.ERROR); console.log('[logger.test] LEVELS ordering ok'); - // 10. emit logs (no throw) + // 11. emit logs (no throw) log.info('hello world'); log.info({ event: 'test' }, 'structured log'); log.warn('warn msg', { extra: 1 }); @@ -96,7 +119,7 @@ async function asyncCheck() { console.log('[logger.test] emit logs ok'); console.log('[logger.test] ok'); -})().catch(err => { +})().catch((err) => { console.error('logger.test FAILED:', err); process.exit(1); }); diff --git a/test/management-latency.test.js b/test/management-latency.test.js index c6022e67..8947c72b 100644 --- a/test/management-latency.test.js +++ b/test/management-latency.test.js @@ -50,15 +50,17 @@ function makeRequestWithProxy({ fail = false, calls }) { return (requestUrl, options, callback) => { calls.push({ requestUrl, options }); const req = new EventEmitter(); - process.nextTick(() => { - if (fail) { - req.emit('error', new Error('network down')); - return; - } - const res = Readable.from([]); - res.statusCode = 200; - callback(res); - }); + req.end = () => { + process.nextTick(() => { + if (fail) { + req.emit('error', new Error('network down')); + return; + } + const res = Readable.from([]); + res.statusCode = 200; + callback(res); + }); + }; return req; }; } @@ -94,6 +96,7 @@ function makeRequestWithProxy({ fail = false, calls }) { assert.ok(typeof JSON.parse(success.body).latency === 'number'); assert.strictEqual(calls[0].requestUrl, 'https://api.openai.test/v1/models?limit=1000'); assert.strictEqual(calls[0].options.headers.authorization, 'Bearer sk-resolved'); + assert.strictEqual(calls[0].options.timeout, 8000); calls = []; const errorHandlers = createManagementLatencyHandlers({ @@ -111,6 +114,21 @@ function makeRequestWithProxy({ fail = false, calls }) { assert.deepStrictEqual(JSON.parse(failed.body), { ok: false, error: 'network down' }); assert.strictEqual(calls[0].options.headers['x-goog-api-key'], 'gemini-key'); + const invalidTimeoutCalls = []; + const invalidTimeoutHandlers = createManagementLatencyHandlers({ + sendJson, + requestWithProxy: makeRequestWithProxy({ calls: invalidTimeoutCalls }), + resolveConfiguredProfile: (profile) => profile, + resolveOfficialCredentials: async ({ profile }) => ({ profile }), + }); + const invalidTimeout = await invoke(invalidTimeoutHandlers.handleLocalLatencyRequest, { + protocol: 'openai', + targetUrl: 'https://api.openai.test/v1', + timeoutMs: 'Infinity', + }); + assert.strictEqual(invalidTimeout.statusCode, 200); + assert.strictEqual(invalidTimeoutCalls[0].options.timeout, 8000); + console.log('[management-latency.test] ok'); })().catch((err) => { console.error(err); diff --git a/test/proxy-transport.test.js b/test/proxy-transport.test.js new file mode 100644 index 00000000..f400aa31 --- /dev/null +++ b/test/proxy-transport.test.js @@ -0,0 +1,144 @@ +const assert = require('assert'); +const http = require('http'); +const { + createProxyTransport, + normalizeTimeout, + shouldBypassProxy, + MAX_BUFFERED_BODY_BYTES, +} = require('../lib/proxy/proxy-transport'); + +function listen(server) { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve(server.address().port)); + }); +} + +function close(server) { + return new Promise((resolve) => server.close(resolve)); +} + +function performRequest(requestWithProxy, targetUrl, options, body) { + return new Promise((resolve, reject) => { + const request = requestWithProxy(targetUrl, options, (response) => { + let responseBody = ''; + response.on('data', (chunk) => (responseBody += chunk)); + response.on('end', () => resolve({ response, body: responseBody })); + }); + request.once('error', reject); + if (body !== undefined) request.write(body); + request.end(); + }); +} + +async function main() { + assert.strictEqual(normalizeTimeout(undefined), 0); + assert.strictEqual(normalizeTimeout('invalid'), 0); + assert.strictEqual(normalizeTimeout(-1), 0); + assert.strictEqual(normalizeTimeout(Infinity), 0); + assert.strictEqual(normalizeTimeout(999_999), 300_000); + assert.strictEqual(shouldBypassProxy(new URL('http://127.0.0.1:8080'), {}), true); + assert.strictEqual( + shouldBypassProxy(new URL('http://api.example.test'), { NO_PROXY: '.example.test' }), + true + ); + assert.strictEqual( + shouldBypassProxy(new URL('http://api.example.test:8080'), { NO_PROXY: 'api.example.test:80' }), + false + ); + + let directBody = ''; + const directServer = http.createServer((request, response) => { + request.on('data', (chunk) => (directBody += chunk)); + request.on('end', () => response.end('direct-ok')); + }); + const directPort = await listen(directServer); + const directTransport = createProxyTransport({ + environment: {}, + loadConfig: () => ({ appSettings: { disableLocalProxy: true } }), + proxyPorts: [], + }); + const directResult = await performRequest( + directTransport.requestWithProxy, + `http://127.0.0.1:${directPort}/submit`, + { method: 'POST', headers: { 'content-type': 'text/plain' } }, + Buffer.from('buffered-body') + ); + assert.strictEqual(directResult.body, 'direct-ok'); + assert.strictEqual(directBody, 'buffered-body'); + await close(directServer); + + let proxyRequest = null; + const proxyServer = http.createServer((request, response) => { + proxyRequest = { url: request.url, authorization: request.headers['proxy-authorization'] }; + response.end('proxy-ok'); + }); + const proxyPort = await listen(proxyServer); + const proxiedTransport = createProxyTransport({ + environment: { HTTP_PROXY: `http://user:pass@127.0.0.1:${proxyPort}` }, + loadConfig: () => ({}), + proxyPorts: [], + }); + const proxyResult = await performRequest( + proxiedTransport.requestWithProxy, + 'http://example.test/path?q=1', + { method: 'GET' } + ); + assert.strictEqual(proxyResult.body, 'proxy-ok'); + assert.strictEqual(proxyRequest.url, 'http://example.test/path?q=1'); + assert.strictEqual(proxyRequest.authorization, 'Basic dXNlcjpwYXNz'); + await close(proxyServer); + + const invalidError = await new Promise((resolve) => { + const request = directTransport.requestWithProxy('file:///tmp/secret', {}, () => {}); + request.once('error', resolve); + request.end(); + }); + assert.match(invalidError.message, /http: or https:/); + + const credentialsError = await new Promise((resolve) => { + const request = directTransport.requestWithProxy( + 'http://user:pass@example.test/', + {}, + () => {} + ); + request.once('error', resolve); + request.end(); + }); + assert.match(credentialsError.message, /must not include credentials/); + + const stalledServer = http.createServer(() => {}); + const stalledPort = await listen(stalledServer); + const timeoutError = await new Promise((resolve) => { + const request = directTransport.requestWithProxy( + `http://127.0.0.1:${stalledPort}/stall`, + { timeout: 30 }, + () => {} + ); + request.once('error', resolve); + request.end(); + }); + assert.match(timeoutError.message, /Request timeout/); + await close(stalledServer); + + const overflowServer = http.createServer(() => {}); + const overflowPort = await listen(overflowServer); + const overflowError = await new Promise((resolve) => { + const request = directTransport.requestWithProxy( + `http://127.0.0.1:${overflowPort}/overflow`, + { method: 'POST' }, + () => {} + ); + request.once('error', resolve); + assert.strictEqual(request.write(Buffer.alloc(MAX_BUFFERED_BODY_BYTES + 1)), false); + }); + assert.match(overflowError.message, /too large/); + await close(overflowServer); + + console.log('[proxy-transport.test] ok'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/test/release-governance.test.js b/test/release-governance.test.js new file mode 100644 index 00000000..7c723e5a --- /dev/null +++ b/test/release-governance.test.js @@ -0,0 +1,86 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { + assertSafeReleasePath, + findSensitiveReleaseEntries, + isPathInside, + validateReleaseDirectory, +} = require('../lib/release/governance'); +const { isInstallerFile, resolveCargoTargetDir } = require('../scripts/collect-tauri-artifacts'); + +const repoRoot = path.resolve(__dirname, '..'); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hpm-release-governance-')); +const dependencies = { alpha: '1.0.0', beta: '^2.0.0' }; + +function write(relative, content = '') { + const target = path.join(tempRoot, ...relative.split('/')); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); +} + +try { + assert.strictEqual( + isPathInside(path.join(repoRoot, 'dist'), path.join(repoRoot, 'dist', 'portable')), + true + ); + assert.strictEqual(isPathInside(path.join(repoRoot, 'dist'), repoRoot), false); + assert.throws(() => assertSafeReleasePath(repoRoot, repoRoot), /must stay inside/); + assert.strictEqual( + assertSafeReleasePath(repoRoot, path.join(repoRoot, 'dist', 'portable')), + path.join(repoRoot, 'dist', 'portable') + ); + assert.strictEqual(isInstallerFile('HTTPS Proxy Manager_0.1.0_x64-setup.exe'), true); + assert.strictEqual(isInstallerFile('debug.pdb'), false); + assert.strictEqual( + resolveCargoTargetDir(repoRoot, 'src-tauri/target-release'), + path.join(repoRoot, 'src-tauri', 'target-release') + ); + + write('openai-proxy.js'); + write('lib/proxy/config.js'); + write('proxy.bat'); + write('proxy.ps1'); + write('proxy.config.example.json', '{}'); + write('runtime/node/node.exe'); + write('https-proxy-manager.exe'); + write('node_modules/alpha/package.json', '{}'); + write('node_modules/beta/package.json', '{}'); + write('node_modules/alpha/.claude/settings.json', '{}'); + write('package.json', JSON.stringify({ private: true, dependencies })); + + let result = validateReleaseDirectory(tempRoot, dependencies); + assert.deepStrictEqual(result.errors, []); + assert.strictEqual(result.ok, true); + + write('proxy.config.json', JSON.stringify({ profiles: [{ apiKey: 'secret' }] })); + write('.hpm-runtime/.certs/proxy-key.pem', '-----BEGIN PRIVATE KEY-----\nsecret'); + const findings = findSensitiveReleaseEntries(tempRoot); + assert.ok(findings.some((finding) => finding.path === 'proxy.config.json')); + assert.ok(findings.some((finding) => finding.path.includes('.hpm-runtime'))); + assert.ok(findings.some((finding) => finding.path.endsWith('proxy-key.pem'))); + + result = validateReleaseDirectory(tempRoot, dependencies); + assert.strictEqual(result.ok, false); + assert.ok(result.errors.some((error) => error.includes('Unsafe release content'))); + + fs.rmSync(path.join(tempRoot, '.hpm-runtime'), { recursive: true, force: true }); + fs.rmSync(path.join(tempRoot, 'proxy.config.json'), { force: true }); + write( + 'package.json', + JSON.stringify({ + dependencies: { alpha: 'wrong', beta: '^2.0.0' }, + scripts: { postinstall: 'bad' }, + }) + ); + result = validateReleaseDirectory(tempRoot, dependencies); + assert.ok(result.errors.some((error) => error.includes('dependency mismatch for alpha'))); + assert.ok(result.errors.some((error) => error.includes('must not contain lifecycle scripts'))); + + console.log('[release-governance.test] ok'); +} finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); +} diff --git a/test/request-observability.test.js b/test/request-observability.test.js new file mode 100644 index 00000000..e76f131d --- /dev/null +++ b/test/request-observability.test.js @@ -0,0 +1,6 @@ +const assert = require('assert'); +const { createRequestObservability } = require('../lib/proxy/request-observability'); + +assert.throws(() => createRequestObservability(), /requires getConfig/); + +console.log('[request-observability.test] ok'); diff --git a/test/request-utils.test.js b/test/request-utils.test.js index e52eec6c..d66dd68a 100644 --- a/test/request-utils.test.js +++ b/test/request-utils.test.js @@ -5,6 +5,7 @@ const { decodeBodyBuffer, estimateTokens, collectLimitedRequestBody, + normalizeByteLimit, } = require('../lib/proxy/request-utils'); function createReq() { @@ -16,6 +17,20 @@ function testDecodeBodyBufferFallsBackOnBadCompressedBody() { assert.strictEqual(decodeBodyBuffer(body, 'gzip'), 'plain'); } +function testDecodeBodyBufferLimitsDecompressedOutput() { + const zlib = require('zlib'); + const compressed = zlib.gzipSync(Buffer.alloc(4096, 'a')); + const decoded = decodeBodyBuffer(compressed, 'gzip', { maxOutputLength: 128 }); + assert.strictEqual(decoded, compressed.toString('utf8')); +} + +function testNormalizeByteLimitRejectsNonFiniteValues() { + assert.strictEqual(normalizeByteLimit(NaN, 10, 1, 100), 10); + assert.strictEqual(normalizeByteLimit(Infinity, 10, 1, 100), 10); + assert.strictEqual(normalizeByteLimit(-5, 10, 1, 100), 1); + assert.strictEqual(normalizeByteLimit(500, 10, 1, 100), 100); +} + function testEstimateTokensHandlesNestedValues() { assert.strictEqual( estimateTokens({ @@ -111,11 +126,31 @@ function testCollectLimitedRequestBodyAbortedCallback() { assert.strictEqual(aborted, 1); } +function testCollectLimitedRequestBodyFailsClosedForInvalidLimit() { + for (const limit of [NaN, Infinity, 'invalid']) { + const req = createReq(); + let result = null; + collectLimitedRequestBody(req, { + limit, + onComplete(nextResult) { + result = nextResult; + }, + }); + req.emit('data', Buffer.from('x')); + req.emit('end'); + assert.strictEqual(result.tooLarge, true); + assert.strictEqual(result.limit, 0); + } +} + testDecodeBodyBufferFallsBackOnBadCompressedBody(); +testDecodeBodyBufferLimitsDecompressedOutput(); +testNormalizeByteLimitRejectsNonFiniteValues(); testEstimateTokensHandlesNestedValues(); testCollectLimitedRequestBodyCombinesChunks(); testCollectLimitedRequestBodyReportsTooLargeOnce(); testCollectLimitedRequestBodyAllowsExactLimit(); testCollectLimitedRequestBodyAbortedCallback(); +testCollectLimitedRequestBodyFailsClosedForInvalidLimit(); console.log('[request-utils.test] ok'); diff --git a/test/runtime-config-controller.test.js b/test/runtime-config-controller.test.js new file mode 100644 index 00000000..04a726ba --- /dev/null +++ b/test/runtime-config-controller.test.js @@ -0,0 +1,76 @@ +const assert = require('assert'); +const { createRuntimeConfigController } = require('../lib/proxy/runtime-config-controller'); + +assert.throws(() => createRuntimeConfigController(), /requires loadConfig and setRuntimeState/); + +let scheduled = 0; +let appliedState = null; +let agent = null; +let watcherOptions = null; +const loadOptions = []; +const protocolOptions = { stale: true }; +const config = { profiles: [{ id: 'main' }], protocolOptions: { mode: 'safe' } }; +const controller = createRuntimeConfigController({ + configFile: 'test-config.json', + createHttpsAgent: () => ({ id: 'agent' }), + debounceReload: (_configFile, _reloadFn, options) => { + watcherOptions = options; + return { scheduleNow: () => (scheduled += 1), close() {} }; + }, + defaultProtocolOptions: { defaulted: true }, + getActiveProfile: () => ({ id: 'main' }), + getClaudeDesktopRoutes: () => [], + getLocalServer: () => ({ listenPort: 3443 }), + getModelRoutes: () => [], + getProfiles: (value) => value.profiles, + loadConfig: (options) => { + loadOptions.push(options); + return config; + }, + log: { info() {}, warn() {}, error() {} }, + metricsInstance: { counter: () => ({ inc() {} }), gauge: () => ({ set() {} }) }, + protocolOptions, + rebuildProxyRouteIndex: () => true, + resetLocalProxyDetectionCache() {}, + setHttpsAgent: (value) => (agent = value), + setRuntimeState: (value) => (appliedState = value), +}); + +controller.reloadProxyConfig(); +assert.strictEqual(scheduled, 1); +controller.applyConfig(config); +assert.deepStrictEqual(protocolOptions, { defaulted: true, mode: 'safe' }); +assert.deepStrictEqual(agent, { id: 'agent' }); +assert.strictEqual(appliedState.activeProfile.id, 'main'); +assert.deepStrictEqual(watcherOptions.loadConfigFn(), config); +assert.deepStrictEqual(loadOptions.pop(), { throwOnError: true }); +controller.close(); + +let manualLoadOptions = null; +const manualController = createRuntimeConfigController({ + configFile: 'test-config.json', + createHttpsAgent: () => ({ id: 'manual-agent' }), + debounceReload: () => null, + defaultProtocolOptions: {}, + getActiveProfile: () => ({ id: 'main' }), + getClaudeDesktopRoutes: () => [], + getLocalServer: () => ({ listenPort: 3443 }), + getModelRoutes: () => [], + getProfiles: (value) => value.profiles, + loadConfig: (options) => { + manualLoadOptions = options; + return config; + }, + log: { info() {}, warn() {}, error() {} }, + metricsInstance: { counter: () => ({ inc() {} }), gauge: () => ({ set() {} }) }, + protocolOptions: {}, + rebuildProxyRouteIndex: () => false, + resetLocalProxyDetectionCache() {}, + setHttpsAgent() {}, + setRuntimeState() {}, +}); +manualController.reloadProxyConfig(); +assert.deepStrictEqual(manualLoadOptions, { throwOnError: true }); +manualController.close(); + +console.log('[runtime-config-controller.test] ok'); diff --git a/test/runtime-support.test.js b/test/runtime-support.test.js new file mode 100644 index 00000000..b7e823ce --- /dev/null +++ b/test/runtime-support.test.js @@ -0,0 +1,24 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { + createRuntimeSupport, + isTauriManagementOrigin, + maskManagementToken, +} = require('../lib/proxy/runtime-support'); + +assert.throws(() => createRuntimeSupport(), /requires dataDir/); +assert.strictEqual(maskManagementToken('short'), '[redacted]'); +assert.strictEqual(maskManagementToken('1234567890abcdef'), '123456...cdef'); +assert.strictEqual(isTauriManagementOrigin({ headers: { origin: 'tauri://localhost' } }), true); +assert.strictEqual(isTauriManagementOrigin({ headers: { origin: 'https://example.test' } }), false); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hpm-runtime-support-')); +const support = createRuntimeSupport({ dataDir }); +const token = support.writeManagementTokenFile(45678); +assert.match(token, /^[0-9a-f]{32}$/); +assert.strictEqual(fs.readFileSync(path.join(dataDir, '.mgmt-token-45678'), 'utf8'), token); +assert.strictEqual(support.readExistingManagementToken(45678), token); + +console.log('[runtime-support.test] ok'); diff --git a/test/secret-crypto.test.js b/test/secret-crypto.test.js index 6d4c3ced..38fdd5ed 100644 --- a/test/secret-crypto.test.js +++ b/test/secret-crypto.test.js @@ -1,15 +1,24 @@ const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); const { encryptKey, decryptKey, + decryptKeysStrict, clearDecryptCache, getCryptoBackend, DPAPI_PREFIX, AES_PREFIX, + SECRET_DECRYPTION_ERROR_CODE, _encryptAes, _decryptAes, } = require('../lib/proxy/secret-crypto'); -const { encryptConfigSecretsForStorage } = require('../lib/proxy/config'); +const { + decryptConfigSecretsInPlace, + encryptConfigSecretsForStorage, +} = require('../lib/proxy/config'); clearDecryptCache(); @@ -62,8 +71,10 @@ assert.ok( cipher.startsWith(DPAPI_PREFIX) || cipher.startsWith(AES_PREFIX), 'cipher must use known prefix: ' + cipher.substring(0, 16) ); -const plain = decryptKey(cipher); +const [plain, duplicatePlain] = decryptKeysStrict([cipher, cipher]); assert.strictEqual(plain, apiKey, 'end-to-end round-trip must match'); +assert.strictEqual(duplicatePlain, apiKey, 'strict decryption must preserve duplicate ordering'); +assert.strictEqual(decryptKey(cipher), apiKey, 'graceful decryption must reuse strict cache'); console.log('[secret-crypto.test] end-to-end round-trip ok prefix=' + cipher.substring(0, 9)); // 7. Backward compatibility: legacy hpm-enc: ciphertext must still decrypt @@ -78,6 +89,10 @@ console.log('[secret-crypto.test] legacy hpm-enc: backward compat ok'); assert.strictEqual(decryptKey('hpm-enc:bad'), 'hpm-enc:bad'); assert.strictEqual(decryptKey('hpm-enc:'), 'hpm-enc:'); assert.strictEqual(decryptKey('hpm-dpapi:invalid'), 'hpm-dpapi:invalid'); +assert.throws( + () => decryptKeysStrict(['hpm-dpapi:invalid']), + (error) => error && error.code === SECRET_DECRYPTION_ERROR_CODE +); console.log('[secret-crypto.test] bad ciphertext graceful ok'); // 9. Config storage encrypts nested secrets and is idempotent. @@ -87,11 +102,16 @@ const storedConfig = encryptConfigSecretsForStorage({ token: 'desktop-token', directApiKey: 'direct-secret', }, + entryAuth: { + enabled: true, + keys: [{ id: 'remote-client', key: 'entry-secret' }], + }, }); assert.match(storedConfig.profiles[0].apiKey, /^hpm-(dpapi|enc):/); assert.match(storedConfig.claudeDesktop.token, /^hpm-(dpapi|enc):/); assert.match(storedConfig.claudeDesktop.directApiKey, /^hpm-(dpapi|enc):/); +assert.match(storedConfig.entryAuth.keys[0].key, /^hpm-(dpapi|enc):/); assert.notStrictEqual(storedConfig.profiles[0].apiKey, 'profile-secret'); assert.notStrictEqual(storedConfig.claudeDesktop.token, 'desktop-token'); assert.notStrictEqual(storedConfig.claudeDesktop.directApiKey, 'direct-secret'); @@ -106,9 +126,52 @@ assert.strictEqual( storedConfigAgain.claudeDesktop.directApiKey, storedConfig.claudeDesktop.directApiKey ); +assert.strictEqual(storedConfigAgain.entryAuth.keys[0].key, storedConfig.entryAuth.keys[0].key); + +const loadedConfig = decryptConfigSecretsInPlace(storedConfigAgain); +assert.strictEqual(loadedConfig.profiles[0].apiKey, 'profile-secret'); +assert.strictEqual(loadedConfig.claudeDesktop.token, 'desktop-token'); +assert.strictEqual(loadedConfig.claudeDesktop.directApiKey, 'direct-secret'); +assert.strictEqual(loadedConfig.entryAuth.keys[0].key, 'entry-secret'); console.log('[secret-crypto.test] config storage encryption ok'); -// 10. getCryptoBackend reports status +// 10. Protected config failures must never fall back to the unauthenticated defaults. +const badConfigRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hpm-bad-secret-')); +try { + fs.writeFileSync( + path.join(badConfigRoot, 'proxy.config.json'), + JSON.stringify({ + entryAuth: { + enabled: true, + keys: [{ id: 'broken', key: 'hpm-dpapi:invalid' }], + }, + }), + 'utf8' + ); + const configModulePath = path.resolve(__dirname, '../lib/proxy/config.js'); + const child = spawnSync( + process.execPath, + [ + '-e', + `const { loadConfig } = require(${JSON.stringify(configModulePath)});` + + `try { loadConfig(); process.exit(2); } catch (error) {` + + `process.exit(error && error.code === ${JSON.stringify(SECRET_DECRYPTION_ERROR_CODE)} ? 0 : 3);` + + `}`, + ], + { + cwd: path.resolve(__dirname, '..'), + env: { ...process.env, PROXY_CONFIG_DIR: badConfigRoot }, + encoding: 'utf8', + timeout: 15000, + } + ); + assert.strictEqual(child.status, 0, child.stderr || child.stdout || 'strict load failed'); +} finally { + fs.rmSync(badConfigRoot, { recursive: true, force: true }); +} +console.log('[secret-crypto.test] protected config failure is fail-closed'); + +// 11. getCryptoBackend reports status const backend = getCryptoBackend(); assert.ok(typeof backend === 'object' && backend !== null); assert.ok('isWindows' in backend); diff --git a/test/server-lifecycle.test.js b/test/server-lifecycle.test.js new file mode 100644 index 00000000..5b71dc7c --- /dev/null +++ b/test/server-lifecycle.test.js @@ -0,0 +1,6 @@ +const assert = require('assert'); +const { startProxyServers } = require('../lib/proxy/server-lifecycle'); + +assert.throws(() => startProxyServers(), /requires getRuntimeState/); + +console.log('[server-lifecycle.test] ok'); diff --git a/test/tauri-release-wiring.test.js b/test/tauri-release-wiring.test.js new file mode 100644 index 00000000..e2ce78f3 --- /dev/null +++ b/test/tauri-release-wiring.test.js @@ -0,0 +1,109 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.resolve(__dirname, '..'); +const rustLib = fs.readFileSync(path.join(repoRoot, 'src-tauri', 'src', 'lib.rs'), 'utf8'); +const rustMain = fs.readFileSync(path.join(repoRoot, 'src-tauri', 'src', 'main.rs'), 'utf8'); +const permissions = fs.readFileSync( + path.join(repoRoot, 'src-tauri', 'permissions', 'app-commands.toml'), + 'utf8' +); +const tauriConfig = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'src-tauri', 'tauri.conf.json'), 'utf8') +); +const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); +const installerHooks = fs.readFileSync( + path.join(repoRoot, 'src-tauri', 'nsis', 'installer-hooks.nsh'), + 'utf8' +); + +function registeredTauriCommands(source) { + const match = source.match(/\.invoke_handler\(tauri::generate_handler!\[([\s\S]*?)\]\)/); + assert.ok(match, 'Tauri invoke handler registration must be present'); + return match[1] + .split(',') + .map((command) => command.trim()) + .filter(Boolean); +} + +function allowedTauriCommands(source) { + const match = source.match(/\[permission\.commands\][\s\S]*?allow\s*=\s*\[([\s\S]*?)\]/); + assert.ok(match, 'Tauri custom command allow-list must be present'); + return Array.from(match[1].matchAll(/"([^"]+)"/g), (entry) => entry[1]); +} + +const registered = registeredTauriCommands(rustLib); +const allowed = new Set(allowedTauriCommands(permissions)); +const missingPermissions = registered.filter((command) => !allowed.has(command)); +assert.deepStrictEqual( + missingPermissions, + [], + `registered Tauri commands missing from ACL: ${missingPermissions.join(', ')}` +); + +const autostartCalls = `${rustMain}\n${rustLib}`.match(/request_proxy_autostart\(\);/g) || []; +assert.strictEqual( + autostartCalls.length, + 1, + 'desktop composition must request proxy autostart once' +); +assert.ok( + !rustMain.includes('request_proxy_autostart();'), + 'main remains a composition root without duplicate proxy lifecycle work' +); + +const setupIndex = rustLib.indexOf('.setup(|app|'); +const resourceRootIndex = rustLib.indexOf('RESOURCE_ROOT.set(resource_dir)', setupIndex); +const autostartIndex = rustLib.indexOf('request_proxy_autostart();', setupIndex); +assert.ok(setupIndex >= 0, 'Tauri setup hook must be present'); +assert.ok(resourceRootIndex > setupIndex, 'setup must resolve the bundled resource root'); +assert.ok( + autostartIndex > resourceRootIndex, + 'proxy autostart must run after the bundled resource root is available' +); + +assert.strictEqual( + tauriConfig.bundle.windows.nsis.installerHooks, + 'nsis/installer-hooks.nsh', + 'NSIS builds must include the process cleanup hooks' +); +assert.strictEqual( + tauriConfig.bundle.windows.nsis.installMode, + 'currentUser', + 'the fixed cleanup directory must match the current-user NSIS install mode' +); +assert.match(installerHooks, /!macro NSIS_HOOK_PREINSTALL/); +assert.match(installerHooks, /!macro NSIS_HOOK_PREUNINSTALL/); +assert.match(installerHooks, /stop-installed-processes\.ps1/); +assert.match(installerHooks, /Process cleanup diagnostics/); +assert.match(installerHooks, /\$1 != ""/); +assert.match(installerHooks, /StrCpy \$R9 "\$LOCALAPPDATA\\HTTPS Proxy Manager"/); +assert.ok( + !installerHooks.includes('-InstallDir'), + 'NSIS must derive the current-user install directory inside PowerShell instead of quoting it on the command line' +); +assert.match( + installerHooks, + /!define HTTPS_PROXY_INSTALLER_HOOK_DIR "\$\{__FILEDIR__\}"/, + 'hook directory must be captured while NSIS reads the source hook, before macro expansion' +); +assert.match( + installerHooks, + /"\$\{HTTPS_PROXY_INSTALLER_HOOK_DIR\}\\stop-installed-processes\.ps1"/, + 'embedded cleanup script must resolve relative to the source hook directory' +); + +const verifyScript = String((packageJson.scripts && packageJson.scripts.verify) || ''); +assert.ok( + verifyScript.indexOf('npm run release:prepare') >= 0, + 'the unified verification gate must prepare Tauri release resources' +); +assert.ok( + verifyScript.indexOf('npm run release:prepare') < verifyScript.indexOf('npm run test:rust'), + 'release resources must be prepared before Rust tests compile the Tauri context' +); + +console.log('[tauri-release-wiring.test] ok'); diff --git a/test/test-sharding.test.js b/test/test-sharding.test.js new file mode 100644 index 00000000..2ef5e5d8 --- /dev/null +++ b/test/test-sharding.test.js @@ -0,0 +1,26 @@ +const assert = require('assert'); +const { selectTestShard } = require('../lib/test-sharding'); + +const tests = ['a', 'b', 'c', 'd', 'e']; + +assert.deepStrictEqual(selectTestShard(tests), { + count: 1, + index: 0, + tests, +}); +assert.deepStrictEqual(selectTestShard(tests, { TEST_SHARD_COUNT: '2', TEST_SHARD_INDEX: '1' }), { + count: 2, + index: 1, + tests: ['b', 'd'], +}); +assert.throws(() => selectTestShard(tests, { TEST_SHARD_COUNT: '0' }), RangeError); +assert.throws( + () => selectTestShard(tests, { TEST_SHARD_COUNT: '2', TEST_SHARD_INDEX: '2' }), + RangeError +); +assert.throws(() => selectTestShard(tests, { TEST_SHARD_COUNT: 'Infinity' }), TypeError); +assert.throws(() => selectTestShard(tests, { TEST_SHARD_INDEX: '-1' }), RangeError); +assert.throws(() => selectTestShard(tests, { TEST_SHARD_COUNT: '6' }), RangeError); +assert.throws(() => selectTestShard(null), TypeError); + +console.log('[test-sharding.test] ok'); diff --git a/test/upstream-retry.test.js b/test/upstream-retry.test.js index ae2e26f6..1fc744f1 100644 --- a/test/upstream-retry.test.js +++ b/test/upstream-retry.test.js @@ -3,10 +3,12 @@ const assert = require('assert'); const { isRetryableStatusCode, shouldRetryAttempt, + parseRetryAfterMs, retryDelayMs, shouldRetryUpstreamStatus, shouldFailoverOnStatus, shouldFailoverOnError, + resolveUpstreamRecovery, } = require('../lib/proxy/upstream-retry'); function testRetryableStatusCodes() { @@ -21,6 +23,9 @@ function testRetryAttemptBoundary() { assert.strictEqual(shouldRetryAttempt(1, 2), true); assert.strictEqual(shouldRetryAttempt(2, 2), true); assert.strictEqual(shouldRetryAttempt(3, 2), false); + assert.strictEqual(shouldRetryAttempt('Infinity', 2), false); + assert.strictEqual(shouldRetryAttempt(1, 'Infinity'), false); + assert.strictEqual(shouldRetryAttempt(NaN, 2), false); } function testRetryDelayBoundsJitter() { @@ -46,6 +51,24 @@ function testRetryDelayBoundsJitter() { ); } +function testRetryAfterParsingAndBounds() { + assert.strictEqual(parseRetryAfterMs('2.5'), 2500); + assert.strictEqual(parseRetryAfterMs('999999'), 120000); + assert.strictEqual(parseRetryAfterMs('invalid'), null); + assert.strictEqual( + parseRetryAfterMs('Wed, 21 Oct 2015 07:28:00 GMT', Date.parse('Wed, 21 Oct 2015 07:27:58 GMT')), + 2000 + ); + assert.strictEqual( + retryDelayMs(1, () => 0, '5'), + 5000 + ); + assert.strictEqual( + retryDelayMs(Infinity, () => 0), + 1000 + ); +} + function testStatusRetryCombinesCodeAndAttempt() { assert.strictEqual(shouldRetryUpstreamStatus(429, 1, 2), true); assert.strictEqual(shouldRetryUpstreamStatus(503, 2, 2), true); @@ -74,11 +97,76 @@ function testErrorFailoverOnlyFirstAttempt() { assert.strictEqual(shouldFailoverOnError(1, prepared, []), null); } +function testRecoveryPrefersFailoverBeforeRetry() { + const backup = { id: 'backup' }; + const prepared = { profile: { backupProfileId: 'backup' } }; + const profiles = [{ id: 'primary' }, backup]; + + assert.deepStrictEqual( + resolveUpstreamRecovery({ + kind: 'status', + statusCode: 503, + attempt: 1, + maxRetries: 2, + prepared, + profiles, + }), + { type: 'failover', profile: backup } + ); + assert.deepStrictEqual( + resolveUpstreamRecovery({ + kind: 'error', + attempt: 1, + maxRetries: 2, + prepared, + profiles, + }), + { type: 'failover', profile: backup } + ); +} + +function testRecoveryRetriesWhenNoBackupExists() { + const statusRecovery = resolveUpstreamRecovery({ + kind: 'status', + statusCode: 429, + attempt: 1, + maxRetries: 2, + prepared: { profile: {} }, + profiles: [], + retryAfter: '5', + random: () => 0, + }); + assert.deepStrictEqual(statusRecovery, { type: 'retry', delayMs: 5000 }); + + const errorRecovery = resolveUpstreamRecovery({ + kind: 'error', + attempt: 2, + maxRetries: 2, + prepared: { profile: {} }, + profiles: [], + random: () => 0, + }); + assert.deepStrictEqual(errorRecovery, { type: 'retry', delayMs: 4000 }); + assert.deepStrictEqual( + resolveUpstreamRecovery({ + kind: 'error', + attempt: 3, + maxRetries: 2, + prepared: { profile: {} }, + profiles: [], + }), + { type: 'none' } + ); +} + testRetryableStatusCodes(); testRetryAttemptBoundary(); testRetryDelayBoundsJitter(); +testRetryAfterParsingAndBounds(); testStatusRetryCombinesCodeAndAttempt(); testStatusFailoverOnlyFirstRetryableAttempt(); testErrorFailoverOnlyFirstAttempt(); +testRecoveryPrefersFailoverBeforeRetry(); +testRecoveryRetriesWhenNoBackupExists(); console.log('[upstream-retry.test] ok'); diff --git a/test/validator.test.js b/test/validator.test.js index 7d2a1ff8..96111979 100644 --- a/test/validator.test.js +++ b/test/validator.test.js @@ -27,6 +27,7 @@ const { }, }); assert.strictEqual(valid.valid, true); + assert.deepStrictEqual(valid.warnings, []); const invalid = validateConfig({ activeProfile: 'missing', @@ -47,6 +48,32 @@ const { assert.ok(invalid.errors.some((error) => error.includes('mcpHub.servers'))); } +{ + const insecureUpstream = validateConfig({ + activeProfile: 'main', + profiles: [ + { + id: 'main', + protocol: 'openai', + targetUrl: 'http://api.example.test/v1', + apiKey: 'encrypted-or-plain', + }, + ], + localServer: { host: 'localhost', listenPort: 3443, basePath: '/v1' }, + }); + assert.strictEqual(insecureUpstream.valid, true); + assert.ok(insecureUpstream.warnings.some((warning) => warning.includes('non-loopback HTTP'))); + + const exposedWithoutAuth = validateConfig({ + activeProfile: 'main', + profiles: [{ id: 'main', protocol: 'openai', targetUrl: 'https://api.example.test/v1' }], + localServer: { host: '0.0.0.0', listenPort: 3443, basePath: '/v1' }, + entryAuth: { enabled: false, keys: [] }, + }); + assert.strictEqual(exposedWithoutAuth.valid, false); + assert.ok(exposedWithoutAuth.errors.some((error) => error.includes('entryAuth.enabled'))); +} + { const valid = validateEntryAuthConfig({ enabled: true,