diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..14a5534b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: cargo + directory: /src-tauri + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..31eb1118 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,144 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + id-token: write + attestations: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + windows-release: + runs-on: windows-latest + env: + WINDOWS_SIGNING_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_BASE64 }} + WINDOWS_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_PASSWORD }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + cache: npm + - run: rustup default stable + - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 + with: + workspaces: src-tauri + - name: Require Windows signing credentials + shell: pwsh + run: | + if ([string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_CERTIFICATE_BASE64)) { + throw 'WINDOWS_SIGNING_CERTIFICATE_BASE64 is required; unsigned releases are forbidden.' + } + if ([string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_CERTIFICATE_PASSWORD)) { + throw 'WINDOWS_SIGNING_CERTIFICATE_PASSWORD is required; unsigned releases are forbidden.' + } + - run: npm ci + - run: npm run verify + - name: Import signing certificate and configure Tauri + shell: pwsh + run: | + $pfxPath = Join-Path $env:RUNNER_TEMP 'hpm-signing.pfx' + [System.IO.File]::WriteAllBytes( + $pfxPath, + [Convert]::FromBase64String($env:WINDOWS_SIGNING_CERTIFICATE_BASE64) + ) + $password = ConvertTo-SecureString $env:WINDOWS_SIGNING_CERTIFICATE_PASSWORD -AsPlainText -Force + $certificate = Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation Cert:\CurrentUser\My -Password $password + if (-not $certificate -or -not $certificate.Thumbprint) { + throw 'Windows signing certificate import failed.' + } + @{ + bundle = @{ + windows = @{ + certificateThumbprint = $certificate.Thumbprint + digestAlgorithm = 'sha256' + timestampUrl = 'http://timestamp.digicert.com' + } + } + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath 'src-tauri\tauri.windows.conf.json' -Encoding UTF8 + "HPM_SIGNING_THUMBPRINT=$($certificate.Thumbprint)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "HPM_SIGNING_PFX=$pfxPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + - run: npm run tauri:build + - name: Verify Authenticode signatures + shell: pwsh + run: | + $signedFiles = @( + Get-ChildItem -LiteralPath 'dist\release\installers' -File | + Where-Object { $_.Extension -in @('.exe', '.msi') } + ) + $appExecutable = Get-ChildItem -LiteralPath 'src-tauri\target\release' -File | + Where-Object { $_.Name -in @('https-proxy-manager.exe', 'app.exe') } | + Select-Object -First 1 + if ($appExecutable) { $signedFiles += $appExecutable } + if ($signedFiles.Count -eq 0) { throw 'No signed Windows release files were found.' } + foreach ($file in $signedFiles) { + $signature = Get-AuthenticodeSignature -LiteralPath $file.FullName + if ($signature.Status -ne 'Valid') { + throw "Invalid Authenticode signature for $($file.FullName): $($signature.Status)" + } + } + - name: Verify checksums + run: node scripts/verify-release-artifacts.js + - name: Generate CycloneDX SBOMs + shell: pwsh + run: | + New-Item -ItemType Directory -Path 'dist\release\sbom' -Force | Out-Null + $npmSbom = npm sbom --sbom-format cyclonedx --sbom-type application --omit dev + if ($LASTEXITCODE -ne 0) { throw 'npm SBOM generation failed.' } + [System.IO.File]::WriteAllLines( + (Resolve-Path 'dist\release\sbom').Path + '\npm-sbom.cdx.json', + [string[]]$npmSbom + ) + cargo install cargo-cyclonedx --version 0.5.9 --locked + if ($LASTEXITCODE -ne 0) { throw 'cargo-cyclonedx installation failed.' } + cargo cyclonedx --manifest-path src-tauri/Cargo.toml --format json --spec-version 1.5 --override-filename rust-sbom.cdx + if ($LASTEXITCODE -ne 0) { throw 'Rust SBOM generation failed.' } + $rustSbom = Get-ChildItem -LiteralPath 'src-tauri' -File | + Where-Object { $_.Name -like 'rust-sbom.cdx*.json' } | + Select-Object -First 1 + if (-not $rustSbom) { throw 'Rust CycloneDX SBOM output was not found.' } + Move-Item -LiteralPath $rustSbom.FullName -Destination 'dist\release\sbom\rust-sbom.cdx.json' -Force + foreach ($sbom in Get-ChildItem -LiteralPath 'dist\release\sbom' -Filter '*.json') { + $parsed = Get-Content -LiteralPath $sbom.FullName -Raw | ConvertFrom-Json + if ($parsed.bomFormat -ne 'CycloneDX') { throw "Invalid CycloneDX SBOM: $($sbom.FullName)" } + } + - name: Installer smoke test + shell: pwsh + run: ./scripts/smoke-test-nsis.ps1 -InstallerDirectory 'dist\release\installers' + - uses: actions/attest@36051bcae73b7c2a8a6945a48cbf80953c6baa35 # v4 + with: + subject-path: dist/release/**/* + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: signed-windows-release-${{ github.ref_name }} + path: dist/release + if-no-files-found: error + - name: Publish GitHub release + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $assets = @( + Get-ChildItem -LiteralPath 'dist\release' -Recurse -File | + ForEach-Object { $_.FullName } + ) + if ($assets.Count -eq 0) { throw 'No release assets were produced.' } + gh release create $env:GITHUB_REF_NAME --verify-tag --generate-notes @assets + - name: Remove signing material + if: always() + shell: pwsh + run: | + if ($env:HPM_SIGNING_THUMBPRINT) { + Remove-Item -LiteralPath "Cert:\CurrentUser\My\$env:HPM_SIGNING_THUMBPRINT" -Force -ErrorAction SilentlyContinue + } + if ($env:HPM_SIGNING_PFX) { + Remove-Item -LiteralPath $env:HPM_SIGNING_PFX -Force -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath 'src-tauri\tauri.windows.conf.json' -Force -ErrorAction SilentlyContinue diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index b291c405..342892ae 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -11,17 +11,19 @@ jobs: node-quality: runs-on: windows-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: npm - run: npm ci - run: npm run check:syntax - run: npm run lint + - run: npm run release:check - run: npm run frontend:check - run: npm run test:frontend - run: npm run frontend:build + - run: npm audit --audit-level=high unit-tests: runs-on: windows-latest @@ -33,19 +35,30 @@ jobs: TEST_SHARD_COUNT: 4 TEST_SHARD_INDEX: ${{ matrix.shard }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: npm - run: npm ci - run: npm run test:unit + coverage: + runs-on: windows-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run test:coverage + integration-tests: runs-on: windows-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: npm @@ -57,9 +70,19 @@ jobs: rust-tests: runs-on: windows-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + cache: npm + - run: rustup default stable + - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 with: workspaces: src-tauri - - run: cargo test --manifest-path src-tauri/Cargo.toml + - run: npm ci + - run: npm run release:prepare + - run: cargo fmt --manifest-path src-tauri/Cargo.toml -- --check + - run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings + - run: npm run test:rust + - run: cargo install cargo-audit --version 0.22.2 --locked + - run: cargo audit --file src-tauri/Cargo.lock diff --git a/.gitignore b/.gitignore index e01d1b02..3a3ebdd6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Dependencies node_modules/ +coverage/ # Local config and secrets proxy.config.json diff --git a/app-environment.js b/app-environment.js new file mode 100644 index 00000000..a7a096ed --- /dev/null +++ b/app-environment.js @@ -0,0 +1,651 @@ +// Extracted domain module. Loaded before app.js; functions resolve shared globals at call time. + +let isRefreshingEnv = false; +let lastEnvStatusRefresh = 0; +let lastRenderedEnvHtml = ''; +let envPendingRefreshTimer = null; +let lastEnvActionToolIds = []; +const envInstallJobsByTool = new Map(); +const lastEnvToolsById = new Map(); +let envInstallPollTimer = null; + +function getPreferredEnvInstallCommand(tool) { + const toolId = tool && tool.id; + if (toolId === 'claude-code') return 'irm https://claude.ai/install.ps1 | iex'; + if (toolId === 'antigravity-cli') return 'irm https://antigravity.google/cli/install.ps1 | iex'; + if (tool && tool.installCommand) return tool.installCommand; + if (tool && tool.packageName) return `npm i -g ${tool.packageName}@latest`; + return ''; +} + +function scheduleEnvPendingRefresh(delayMs = 2500) { + if (envPendingRefreshTimer) clearTimeout(envPendingRefreshTimer); + envPendingRefreshTimer = setTimeout(() => { + envPendingRefreshTimer = null; + refreshEnvStatus({ passive: true, force: true }); + }, delayMs); +} + +function rememberEnvInstallJobs(results = []) { + let hasRunning = false; + for (const result of results) { + if (!result || !result.jobId || !result.id) continue; + envInstallJobsByTool.set(result.id, { + jobId: result.jobId, + toolId: result.id, + name: result.name || result.id, + status: result.status || 'running', + ok: result.ok, + error: result.error || '', + startedAt: result.startedAt || Date.now(), + endedAt: result.endedAt || null, + installCommand: result.installCommand || '', + usesIrm: !!result.usesIrm, + lastLine: result.lastLine || result.error || result.outputTail || '任务已启动', + outputTail: result.outputTail || '', + }); + if (result.status !== 'succeeded' && result.status !== 'failed') { + hasRunning = true; + } + } + if (hasRunning) startEnvInstallPolling(); + updateEnvInstallProgressPanel(); +} + +function envJobStatusLabel(status) { + if (status === 'succeeded') return '完成'; + if (status === 'failed') return '失败'; + if (status === 'verifying') return '复检中'; + if (status === 'submitting') return '提交中'; + return '执行中'; +} + +function envDataSignature(tools = []) { + return JSON.stringify({ + tools: tools.map((tool) => ({ + id: tool && tool.id, + name: tool && tool.name, + current: tool && tool.current, + latest: tool && tool.latest, + hasUpdate: !!(tool && tool.hasUpdate), + })), + jobs: Array.from(envInstallJobsByTool.values()).map((job) => ({ + toolId: job && job.toolId, + status: job && job.status, + lastLine: job && job.lastLine, + outputTail: job && job.outputTail, + installCommand: job && job.installCommand, + usesIrm: !!(job && job.usesIrm), + })), + }); +} + +function setGridNotice(element) { + if (!element) return; + element.style.gridColumn = '1 / -1'; +} + +function createEnvCodeBlock(text, maxHeight = '42px') { + const code = document.createElement('code'); + code.style.display = 'block'; + code.style.maxHeight = maxHeight; + code.style.overflow = 'auto'; + code.style.whiteSpace = 'pre-wrap'; + code.style.wordBreak = 'break-word'; + code.style.color = 'var(--text)'; + code.style.fontFamily = 'var(--font-mono)'; + code.style.fontSize = '11.5px'; + code.style.lineHeight = '1.35'; + code.textContent = text; + return code; +} + +function createEnvProgressItem(job) { + const failed = job.status === 'failed'; + const succeeded = job.status === 'succeeded'; + const item = document.createElement('div'); + item.style.display = 'grid'; + item.style.gridTemplateColumns = 'minmax(120px, 180px) minmax(100px, 140px) 1fr'; + item.style.gap = '10px'; + item.style.alignItems = 'start'; + item.style.padding = '8px 10px'; + item.style.border = `1px solid ${failed ? 'rgba(239, 68, 68, 0.35)' : 'var(--border)'}`; + item.style.borderRadius = '6px'; + item.style.background = succeeded ? 'rgba(16, 185, 129, 0.08)' : 'var(--card-bg)'; + + const info = document.createElement('div'); + const name = document.createElement('div'); + name.style.fontWeight = '700'; + name.style.color = 'var(--text)'; + name.textContent = job.name || job.toolId || ''; + const method = document.createElement('div'); + method.className = 'muted'; + method.style.fontSize = '12px'; + method.textContent = job.usesIrm ? '官方 irm 安装脚本' : 'npm 全局安装'; + info.append(name, method); + + const status = document.createElement('strong'); + status.style.color = failed ? '#ef4444' : 'var(--text)'; + status.textContent = envJobStatusLabel(job.status); + + item.append( + info, + status, + createEnvCodeBlock( + job.lastLine || job.outputTail || job.installCommand || '等待输出...', + '48px' + ) + ); + return item; +} + +function updateEnvInstallProgressPanel() { + const panel = $('envInstallProgressPanel'); + if (!panel) return; + const jobs = Array.from(envInstallJobsByTool.values()).sort( + (a, b) => (b.startedAt || 0) - (a.startedAt || 0) + ); + if (jobs.length === 0) { + panel.style.display = 'none'; + clearElement(panel); + return; + } + + panel.style.display = 'block'; + clearElement(panel); + const header = document.createElement('div'); + header.style.display = 'flex'; + header.style.alignItems = 'center'; + header.style.justifyContent = 'space-between'; + header.style.gap = '12px'; + header.style.marginBottom = '8px'; + const title = document.createElement('strong'); + title.style.fontSize = '13px'; + title.style.color = 'var(--text)'; + title.textContent = '安装/升级进度'; + const hint = document.createElement('span'); + hint.className = 'muted'; + hint.style.fontSize = '12px'; + hint.textContent = '显示最近任务输出,完成后会自动刷新版本状态'; + header.append(title, hint); + + const list = document.createElement('div'); + list.style.display = 'grid'; + list.style.gap = '8px'; + jobs.forEach((job) => list.appendChild(createEnvProgressItem(job))); + panel.append(header, list); +} + +function markEnvInstallSubmitting(toolIds = []) { + const now = Date.now(); + for (const toolId of toolIds.filter(Boolean)) { + const tool = lastEnvToolsById.get(toolId) || { id: toolId, name: toolId }; + const installCommand = getPreferredEnvInstallCommand(tool); + envInstallJobsByTool.set(toolId, { + jobId: `pending-${toolId}-${now}`, + toolId, + name: tool.name || toolId, + status: 'submitting', + installCommand, + usesIrm: installCommand.startsWith('irm '), + startedAt: now, + lastLine: '正在提交安装任务...', + outputTail: '', + }); + } + updateEnvInstallProgressPanel(); + lastRenderedEnvHtml = ''; +} + +function finalizeEnvInstallSubmission(toolIds = [], results = [], fallbackError = '') { + const byId = new Map((results || []).map((result) => [result && result.id, result])); + for (const toolId of toolIds.filter(Boolean)) { + const result = byId.get(toolId); + if (result && result.jobId) continue; + const current = envInstallJobsByTool.get(toolId); + if (!current || !String(current.jobId || '').startsWith('pending-')) continue; + const failed = fallbackError || (result && result.ok === false); + envInstallJobsByTool.set(toolId, { + ...current, + status: failed ? 'failed' : 'succeeded', + endedAt: Date.now(), + error: fallbackError || (result && result.error) || '', + lastLine: failed + ? fallbackError || (result && result.error) || '安装任务失败' + : '安装/升级完成', + }); + } + updateEnvInstallProgressPanel(); +} + +async function pollEnvInstallJobs() { + const jobIds = Array.from(envInstallJobsByTool.values()) + .filter((job) => job && job.jobId && job.status !== 'succeeded' && job.status !== 'failed') + .map((job) => job.jobId); + if (jobIds.length === 0) { + if (envInstallPollTimer) clearInterval(envInstallPollTimer); + envInstallPollTimer = null; + updateEnvInstallProgressPanel(); + return; + } + + const res = await window.proxyManager.envInstallJobs(jobIds); + if (!res.ok || !Array.isArray(res.jobs)) return; + let changed = false; + let allDone = true; + for (const job of res.jobs) { + if (!job || !job.toolId) continue; + envInstallJobsByTool.set(job.toolId, job); + changed = true; + if (job.status !== 'succeeded' && job.status !== 'failed') allDone = false; + } + if (changed) { + updateEnvInstallProgressPanel(); + lastRenderedEnvHtml = ''; + refreshEnvStatus({ passive: true, force: true }); + } + if (allDone) { + if (envInstallPollTimer) clearInterval(envInstallPollTimer); + envInstallPollTimer = null; + setTimeout(() => refreshEnvStatus({ passive: true, force: true }), 1200); + } +} + +function startEnvInstallPolling() { + if (envInstallPollTimer) return; + envInstallPollTimer = setInterval(() => { + pollEnvInstallJobs().catch((err) => console.error('环境安装进度轮询失败:', err)); + }, 1200); + pollEnvInstallJobs().catch((err) => console.error('环境安装进度轮询失败:', err)); +} + +function createEnvSkeletonCard() { + const card = document.createElement('div'); + card.className = 'card'; + card.style.padding = '16px'; + card.style.display = 'flex'; + card.style.flexDirection = 'column'; + card.style.minHeight = '120px'; + card.style.opacity = '0.7'; + + const top = document.createElement('div'); + top.style.display = 'flex'; + top.style.justifyContent = 'space-between'; + top.style.alignItems = 'flex-start'; + top.style.marginBottom = '12px'; + const identity = document.createElement('div'); + identity.style.display = 'flex'; + identity.style.alignItems = 'center'; + identity.style.gap = '8px'; + const dot = document.createElement('span'); + dot.className = 'status-dot'; + dot.style.margin = '0'; + dot.style.background = 'var(--border)'; + const title = document.createElement('strong'); + title.style.width = '80px'; + title.style.height = '16px'; + title.style.background = 'var(--border)'; + title.style.borderRadius = '2px'; + identity.append(dot, title); + top.appendChild(identity); + + const rows = document.createElement('div'); + rows.style.display = 'flex'; + rows.style.flexDirection = 'column'; + rows.style.gap = '8px'; + ['60%', '80%'].forEach((width) => { + const row = document.createElement('div'); + row.style.display = 'flex'; + row.style.justifyContent = 'space-between'; + row.style.height = '14px'; + row.style.background = 'var(--border)'; + row.style.borderRadius = '2px'; + row.style.width = width; + rows.appendChild(row); + }); + card.append(top, rows); + return card; +} + +function renderEnvSkeleton(grid) { + clearElement(grid); + for (let i = 0; i < 6; i++) grid.appendChild(createEnvSkeletonCard()); +} + +function createEnvActionButton(tool, isInstalled, isJobRunning) { + const button = document.createElement('button'); + button.style.margin = '0'; + button.style.padding = '4px 8px'; + button.style.fontSize = '12px'; + button.style.height = '26px'; + button.style.lineHeight = '1'; + if (isJobRunning) { + button.className = 'light'; + button.disabled = true; + button.style.opacity = '0.7'; + button.textContent = '执行中'; + return button; + } + if (!isInstalled || tool.hasUpdate) { + button.className = 'primary'; + button.dataset.upgradeTool = tool.id || ''; + button.dataset.upgradeAction = isInstalled ? 'upgrade' : 'install'; + button.textContent = isInstalled ? '升级' : '安装'; + return button; + } + button.className = 'light'; + button.disabled = true; + button.style.opacity = '0.5'; + button.textContent = '最新'; + return button; +} + +function createEnvProgressBlock(installJob) { + if (!installJob) return null; + const failed = installJob.status === 'failed'; + const block = document.createElement('div'); + block.style.display = 'flex'; + block.style.flexDirection = 'column'; + block.style.gap = '4px'; + block.style.marginTop = '4px'; + block.style.padding = '8px 10px'; + block.style.border = `1px solid ${failed ? 'rgba(239, 68, 68, 0.35)' : 'var(--border)'}`; + block.style.borderRadius = '6px'; + block.style.background = + installJob.status === 'succeeded' ? 'rgba(16, 185, 129, 0.08)' : 'var(--bg-hover)'; + + const line = document.createElement('div'); + line.style.display = 'flex'; + line.style.justifyContent = 'space-between'; + line.style.gap = '8px'; + line.style.alignItems = 'center'; + const label = document.createElement('span'); + label.className = 'muted'; + label.textContent = '安装进度'; + const status = document.createElement('strong'); + status.style.color = failed ? '#ef4444' : 'var(--text)'; + status.textContent = envJobStatusLabel(installJob.status); + line.append(label, status); + + const method = document.createElement('div'); + method.className = 'muted'; + method.style.fontSize = '12px'; + method.textContent = installJob.usesIrm ? '官方 irm 安装脚本' : 'npm 全局安装'; + block.append( + line, + method, + createEnvCodeBlock(installJob.lastLine || installJob.outputTail || '等待输出...') + ); + return block; +} + +function createEnvInfoRow(labelText, valueText) { + const row = document.createElement('div'); + row.style.display = 'flex'; + row.style.justifyContent = 'space-between'; + const label = document.createElement('span'); + label.className = 'muted'; + label.textContent = labelText; + const value = document.createElement('strong'); + value.style.color = 'var(--text)'; + value.textContent = valueText; + row.append(label, value); + return row; +} + +function createEnvToolCard(tool) { + const isInstalled = !!tool.current; + const installJob = envInstallJobsByTool.get(tool.id); + const isJobRunning = + installJob && installJob.status !== 'succeeded' && installJob.status !== 'failed'; + const card = document.createElement('div'); + card.className = 'card env-card'; + card.style.padding = '16px'; + card.style.display = 'flex'; + card.style.flexDirection = 'column'; + card.style.minHeight = '142px'; + card.style.border = `1px solid ${!isInstalled || tool.hasUpdate ? 'var(--primary)' : 'var(--border)'}`; + card.style.background = + !isInstalled || tool.hasUpdate ? 'rgba(var(--primary-rgb), 0.05)' : 'var(--card-bg)'; + + const header = document.createElement('div'); + header.style.display = 'flex'; + header.style.justifyContent = 'space-between'; + header.style.alignItems = 'flex-start'; + header.style.marginBottom = '12px'; + const identity = document.createElement('div'); + identity.style.display = 'flex'; + identity.style.alignItems = 'center'; + identity.style.gap = '8px'; + const dot = document.createElement('span'); + dot.className = `status-dot ${isInstalled ? 'green' : 'amber'}`; + dot.style.margin = '0'; + const name = document.createElement('strong'); + name.style.fontSize = '15px'; + name.style.color = 'var(--text)'; + name.textContent = tool.name || tool.id || ''; + identity.append(dot, name); + header.append(identity, createEnvActionButton(tool, isInstalled, isJobRunning)); + + const body = document.createElement('div'); + body.style.display = 'flex'; + body.style.flexDirection = 'column'; + body.style.gap = '6px'; + body.style.fontSize = '13px'; + body.style.flex = '1'; + body.append( + createEnvInfoRow('当前版本', tool.current || '未安装'), + createEnvInfoRow('最新版本', tool.latest || '加载中...') + ); + + const commandBlock = document.createElement('div'); + commandBlock.style.display = 'flex'; + commandBlock.style.flexDirection = 'column'; + commandBlock.style.gap = '4px'; + commandBlock.style.marginTop = '4px'; + const commandLabel = document.createElement('span'); + commandLabel.className = 'muted'; + commandLabel.textContent = '安装命令'; + const command = document.createElement('code'); + command.style.display = 'block'; + command.style.padding = '6px 8px'; + command.style.border = '1px solid var(--border)'; + command.style.borderRadius = '4px'; + command.style.background = 'var(--bg-hover)'; + command.style.color = 'var(--text)'; + command.style.fontFamily = 'var(--font-mono)'; + command.style.fontSize = '12px'; + command.style.lineHeight = '1.35'; + command.style.overflowX = 'auto'; + command.style.whiteSpace = 'nowrap'; + command.textContent = getPreferredEnvInstallCommand(tool); + commandBlock.append(commandLabel, command); + body.appendChild(commandBlock); + const progress = createEnvProgressBlock(installJob); + if (progress) body.appendChild(progress); + card.append(header, body); + return card; +} + +async function refreshEnvStatus(options = {}) { + const now = Date.now(); + if ( + options.passive && + !options.force && + lastRenderedEnvHtml && + now - lastEnvStatusRefresh < 30000 + ) + return; + if (isRefreshingEnv) return; + isRefreshingEnv = true; + const finishPerf = perfStart('refresh.envStatus'); + + const grid = $('envGrid'); + const btnRefresh = $('btnRefreshEnv'); + const svgRefresh = $('svgRefreshEnv'); + const btnUpgradeAll = $('btnUpgradeAllEnv'); + + if (btnRefresh) btnRefresh.disabled = true; + if (svgRefresh) svgRefresh.style.animation = 'spin 1s linear infinite'; + + // 预渲染加载骨架;被动刷新已有结果时不闪烁列表。 + if (!options.passive || !lastRenderedEnvHtml) { + renderEnvSkeleton(grid); + } + + try { + const res = await window.proxyManager.envCheck(); + if (!res.ok) throw new Error(res.error || '检查失败'); + + let updateCount = 0; + let installCount = 0; + const actionToolIds = []; + const tools = Array.isArray(res.tools) ? res.tools : []; + tools.forEach((tool) => { + if (tool && tool.id) lastEnvToolsById.set(tool.id, tool); + const isInstalled = !!(tool && tool.current); + if (!isInstalled) installCount++; + if (tool && tool.hasUpdate) updateCount++; + if (tool && (!isInstalled || tool.hasUpdate)) actionToolIds.push(tool.id); + }); + lastEnvActionToolIds = actionToolIds.filter(Boolean); + const envSignature = envDataSignature(tools); + if (envSignature !== lastRenderedEnvHtml) { + grid.replaceChildren(...tools.map((tool) => createEnvToolCard(tool || {}))); + lastRenderedEnvHtml = envSignature; + } + + const actionCount = updateCount + installCount; + btnUpgradeAll.textContent = + actionCount > 0 ? `全部安装/升级 (${actionCount})` : '全部安装/升级 (0)'; + btnUpgradeAll.disabled = actionCount === 0; + lastEnvStatusRefresh = Date.now(); + if (res.latestPending) { + scheduleEnvPendingRefresh(); + } + } catch (err) { + showToast('环境检查刷新失败: ' + err.message, 'error'); + const notice = document.createElement('div'); + notice.className = 'notice'; + setGridNotice(notice); + notice.textContent = `刷新失败,请检查网络或代理服务器运行状态:${err.message}`; + grid.replaceChildren(notice); + } finally { + isRefreshingEnv = false; + if (btnRefresh) btnRefresh.disabled = false; + if (svgRefresh) { + svgRefresh.style.animation = ''; + void svgRefresh.offsetWidth; + } + finishPerf(); + } +} + +window.upgradeSingleTool = async function (toolId, action = 'upgrade') { + const isInstall = action === 'install'; + const confirmed = await confirmAction({ + title: isInstall ? '安装助手工具' : '升级助手工具', + message: `确认要${isInstall ? '安装' : '升级'}该 AI 命令行工具吗?这将在后台执行全局安装命令,耗时可能较长。`, + confirmText: isInstall ? '确定安装' : '确定升级', + }); + if (!confirmed) return; + + markEnvInstallSubmitting([toolId]); + lastRenderedEnvHtml = ''; + refreshEnvStatus({ passive: true, force: true }); + showToast(isInstall ? '正在安装工具,请稍候...' : '正在升级工具,请稍候...', 'success'); + try { + const res = await window.proxyManager.envUpgrade(toolId); + if (!res.ok) throw new Error(res.error || (isInstall ? '安装失败' : '升级失败')); + + const result = res.results && res.results[0]; + if (result && !result.ok) { + throw new Error(result.error || '安装失败'); + } + + rememberEnvInstallJobs(res.results || []); + finalizeEnvInstallSubmission([toolId], res.results || []); + showToast( + isInstall ? '安装任务已启动,正在显示进度...' : '升级任务已启动,正在显示进度...', + 'success' + ); + refreshEnvStatus({ passive: true, force: true }); + } catch (err) { + finalizeEnvInstallSubmission([toolId], [], err.message); + showToast((isInstall ? '工具安装失败:' : '工具升级失败:') + err.message, 'error'); + } +}; + +async function upgradeAllEnv() { + const confirmed = await confirmAction({ + title: '全部安装/升级', + message: + '确认要安装缺失工具并升级有更新的 AI 命令行工具吗?这将在后台依次串行执行全局安装命令,需要数分钟时间。', + confirmText: '全部安装/升级', + }); + if (!confirmed) return; + + showToast('正在执行全部升级,可能需要几分钟时间,请勿关闭本管理器...', 'success'); + const btnUpgradeAll = $('btnUpgradeAllEnv'); + if (btnUpgradeAll) { + btnUpgradeAll.disabled = true; + btnUpgradeAll.textContent = '正在安装/升级...'; + } + const batchToolIds = lastEnvActionToolIds.slice(); + markEnvInstallSubmitting(batchToolIds); + lastRenderedEnvHtml = ''; + refreshEnvStatus({ passive: true, force: true }); + + try { + const res = await window.proxyManager.envUpgrade(batchToolIds); + if (!res.ok) throw new Error(res.error || '升级失败'); + + rememberEnvInstallJobs(res.results || []); + finalizeEnvInstallSubmission(batchToolIds, res.results || []); + const failed = res.results.filter((r) => !r.ok); + if (failed.length > 0) { + showToast(`部分包升级失败: ${failed.map((f) => f.name).join(', ')}`, 'error'); + } else { + showToast('安装/升级任务已启动,正在显示进度...', 'success'); + } + refreshEnvStatus({ passive: true, force: true }); + } catch (err) { + finalizeEnvInstallSubmission(batchToolIds, [], err.message); + showToast('全部升级失败:' + err.message, 'error'); + } finally { + if (btnUpgradeAll) btnUpgradeAll.disabled = false; + } +} + +function bindEnvControls() { + const refreshButton = $('btnRefreshEnv'); + const upgradeAllButton = $('btnUpgradeAllEnv'); + const diagnosticsButton = $('btnDiagnosticsConflicts'); + const grid = $('envGrid'); + + if (refreshButton) { + refreshButton.onclick = () => refreshEnvStatus(); + refreshButton.dataset.bound = 'true'; + } + if (upgradeAllButton) { + upgradeAllButton.onclick = () => upgradeAllEnv(); + upgradeAllButton.dataset.bound = 'true'; + } + if (diagnosticsButton) { + diagnosticsButton.onclick = async () => { + showToast('正在诊断安装冲突与分析包依赖...', 'success'); + setTimeout(() => { + showToast('依赖分析完毕,未检测到包冲突!所有 CLI 工具全局运行环境良好。', 'success'); + }, 1000); + }; + } + if (grid) { + grid.onclick = (event) => { + if (!(event.target instanceof Element)) return; + const button = event.target.closest('[data-upgrade-tool]'); + if (!button || !grid.contains(button)) return; + window.upgradeSingleTool(button.dataset.upgradeTool, button.dataset.upgradeAction); + }; + grid.dataset.bound = 'true'; + } +} diff --git a/app-mcp.js b/app-mcp.js new file mode 100644 index 00000000..3e80b2f0 --- /dev/null +++ b/app-mcp.js @@ -0,0 +1,309 @@ +// Extracted domain module. Loaded before app.js; functions resolve shared globals at call time. + +// --- MCP Hub UI Logic --- + +let mcpHubConfig = { servers: [], routing: [] }; + +const GLM_MCP_TEMPLATES = { + webSearchPrime: { + id: 'glm-web-search', + url: 'https://open.bigmodel.cn/api/mcp/web_search_prime/mcp', + prefix: 'glm_search', + injectAll: true, + }, +}; + +async function initMcpHubView() { + const container = $('mcpHubView'); + if (!container || container.dataset.initialized) { + refreshMcpHub(); + return; + } + container.dataset.initialized = 'true'; + + $('refreshMcpHub').onclick = () => refreshMcpHub(); + $('addMcpServer').onclick = () => addMcpServer(); + const addGlmSearchButton = $('addGlmSearchMcpServer'); + if (addGlmSearchButton) addGlmSearchButton.onclick = () => addGlmSearchMcpServer(); + $('saveMcpHub').onclick = () => saveMcpHub(); + + refreshMcpHub(); +} + +async function refreshMcpHub() { + const status = $('mcpHubStatus'); + status.textContent = '正在加载 MCP 配置...'; + + try { + const res = await window.proxyManager.mcpHubGet(); + if (res.ok) { + mcpHubConfig = res.mcpHub; + renderMcpServers(); + status.textContent = `已加载 ${mcpHubConfig.servers.length} 个服务器,${mcpHubConfig.routing.length} 条路由规则。`; + } else { + status.textContent = '加载 MCP 配置失败:' + (res.error || '未知错误'); + } + + const logsRes = await window.proxyManager.mcpHubLogs(); + if (logsRes.ok) { + renderMcpLogs(logsRes.logs); + } + } catch (err) { + status.textContent = '加载失败:' + err.message; + } +} + +function renderMcpServers() { + const container = $('mcpServersList'); + clearElement(container); + if (!mcpHubConfig || !mcpHubConfig.servers || mcpHubConfig.servers.length === 0) { + container.appendChild(createMcpServersEmptyElement()); + return; + } + + container.appendChild(createMcpServersTableElement(mcpHubConfig.servers)); +} + +function createMcpServersEmptyElement() { + const empty = document.createElement('div'); + empty.className = 'muted'; + empty.style.padding = '20px'; + empty.style.textAlign = 'center'; + empty.textContent = '暂无已配置的 MCP 服务器。'; + return empty; +} + +function createMcpServersTableElement(servers) { + const wrapper = document.createElement('div'); + wrapper.className = 'route-table'; + const head = document.createElement('div'); + head.className = 'route-table-head'; + const headTable = document.createElement('table'); + const thead = document.createElement('thead'); + const headerRow = document.createElement('tr'); + [ + ['服务器 ID', ''], + ['URL (HTTP/SSE)', ''], + ['前缀', ''], + ['Bearer Token', '180px'], + ['注入', '60px'], + ['操作', '80px'], + ].forEach(([label, width]) => { + const th = document.createElement('th'); + th.textContent = label; + if (width) th.style.width = width; + headerRow.appendChild(th); + }); + thead.appendChild(headerRow); + headTable.appendChild(thead); + head.appendChild(headTable); + + const scroll = document.createElement('div'); + scroll.className = 'route-table-scroll'; + const bodyTable = document.createElement('table'); + const tbody = document.createElement('tbody'); + servers.forEach((server, index) => tbody.appendChild(createMcpServerRow(server, index))); + bodyTable.appendChild(tbody); + scroll.appendChild(bodyTable); + wrapper.append(head, scroll); + return wrapper; +} + +function createMcpServerInput(server, index, field, placeholder = '') { + const input = document.createElement('input'); + input.type = field === 'bearerToken' ? 'password' : 'text'; + input.value = String(server[field] || ''); + input.className = 'table-input'; + if (placeholder) input.placeholder = placeholder; + input.addEventListener('change', () => updateMcpServer(index, field, input.value)); + return input; +} + +function createMcpServerRow(server, index) { + const row = document.createElement('tr'); + + const idCell = document.createElement('td'); + idCell.appendChild(createMcpServerInput(server, index, 'id')); + row.appendChild(idCell); + + const urlCell = document.createElement('td'); + urlCell.appendChild(createMcpServerInput(server, index, 'url', 'http://...')); + row.appendChild(urlCell); + + const prefixCell = document.createElement('td'); + prefixCell.appendChild(createMcpServerInput(server, index, 'prefix', '可选')); + row.appendChild(prefixCell); + + const tokenCell = document.createElement('td'); + tokenCell.appendChild(createMcpServerInput(server, index, 'bearerToken', 'GLM Coding Plan Key')); + row.appendChild(tokenCell); + + const injectCell = document.createElement('td'); + injectCell.style.textAlign = 'center'; + const injectCheckbox = document.createElement('input'); + injectCheckbox.type = 'checkbox'; + injectCheckbox.checked = Boolean(server.injectAll); + injectCheckbox.addEventListener('change', () => + updateMcpServer(index, 'injectAll', injectCheckbox.checked) + ); + injectCell.appendChild(injectCheckbox); + row.appendChild(injectCell); + + const actionCell = document.createElement('td'); + actionCell.style.textAlign = 'right'; + const deleteButton = document.createElement('button'); + deleteButton.className = 'danger'; + deleteButton.style.padding = '2px 8px'; + deleteButton.style.fontSize = '12px'; + deleteButton.style.margin = '0'; + deleteButton.textContent = '删除'; + deleteButton.addEventListener('click', () => deleteMcpServer(index)); + actionCell.appendChild(deleteButton); + row.appendChild(actionCell); + + return row; +} + +function updateMcpServer(index, field, value) { + if (mcpHubConfig.servers[index]) { + mcpHubConfig.servers[index][field] = value; + } +} + +function deleteMcpServer(index) { + mcpHubConfig.servers.splice(index, 1); + renderMcpServers(); +} + +function addMcpServer() { + if (!mcpHubConfig.servers) mcpHubConfig.servers = []; + mcpHubConfig.servers.push({ + id: 'mcp-server-' + Math.random().toString(36).substring(2, 6), + url: 'http://localhost:3000/sse', + prefix: '', + injectAll: true, + }); + renderMcpServers(); +} + +function addGlmSearchMcpServer() { + if (!mcpHubConfig.servers) mcpHubConfig.servers = []; + if (!mcpHubConfig.routing) mcpHubConfig.routing = []; + + const template = GLM_MCP_TEMPLATES.webSearchPrime; + const existing = mcpHubConfig.servers.find((server) => server && server.id === template.id); + if (existing) { + Object.assign(existing, { + url: template.url, + prefix: existing.prefix || template.prefix, + injectAll: true, + }); + } else { + mcpHubConfig.servers.push({ + ...template, + bearerToken: '', + }); + } + + const hasRoute = mcpHubConfig.routing.some( + (route) => route && route.serverId === template.id && route.pattern === '^glm_search_' + ); + if (!hasRoute) { + mcpHubConfig.routing.push({ pattern: '^glm_search_', serverId: template.id }); + } + renderMcpServers(); + const status = $('mcpHubStatus'); + if (status) { + status.textContent = + '已添加 GLM 联网搜索 MCP 模板。请在 Bearer Token 中填入 GLM Coding Plan API Key 后保存。'; + } +} + +function createMcpLogsEmptyElement() { + const empty = document.createElement('div'); + empty.className = 'muted'; + empty.textContent = '暂无执行记录...'; + return empty; +} + +function createMcpLogEntryElement(log) { + const entry = document.createElement('div'); + entry.className = 'log-entry'; + entry.style.marginBottom = '12px'; + entry.style.borderBottom = '1px solid var(--border)'; + entry.style.paddingBottom = '8px'; + + const header = document.createElement('div'); + header.style.display = 'flex'; + header.style.justifyContent = 'space-between'; + header.style.alignItems = 'center'; + + const title = document.createElement('div'); + const time = document.createElement('span'); + time.className = 'muted'; + time.textContent = `[${new Date(log.time).toLocaleTimeString()}] `; + title.appendChild(time); + + const server = document.createElement('strong'); + server.style.color = 'var(--text)'; + server.textContent = log.serverId || '-'; + title.appendChild(server); + title.appendChild(document.createTextNode(': ')); + + const tool = document.createElement('code'); + tool.style.color = 'var(--primary)'; + tool.style.fontWeight = '600'; + tool.textContent = log.toolName || '-'; + title.appendChild(tool); + + const badge = document.createElement('span'); + badge.className = `badge ${log.status === 'error' ? 'red' : 'green'}`; + badge.textContent = `${log.status === 'error' ? '失败' : '成功'} (${log.elapsed || 0}ms)`; + + header.appendChild(title); + header.appendChild(badge); + entry.appendChild(header); + + const metadata = document.createElement('div'); + metadata.className = 'muted'; + metadata.style.marginTop = '4px'; + const inputBytes = Number.isFinite(Number(log.inputBytes)) ? Number(log.inputBytes) : 0; + const resultBytes = Number.isFinite(Number(log.resultBytes)) ? Number(log.resultBytes) : 0; + const errorType = log.errorType ? ` · 错误类型 ${log.errorType}` : ''; + metadata.textContent = `输入 ${inputBytes} B · 输出 ${resultBytes} B${errorType}`; + entry.appendChild(metadata); + return entry; +} + +function renderMcpLogs(logs) { + const container = $('mcpExecutionLogs'); + container.textContent = ''; + if (!logs || logs.length === 0) { + container.appendChild(createMcpLogsEmptyElement()); + return; + } + + logs.forEach((log) => container.appendChild(createMcpLogEntryElement(log))); +} + +async function saveMcpHub() { + const btn = $('saveMcpHub'); + const origText = btn.textContent; + btn.disabled = true; + btn.textContent = '正在保存...'; + + try { + const res = await window.proxyManager.mcpHubSave({ mcpHub: mcpHubConfig }); + if (res.ok) { + showToast('MCP 配置已保存', 'success'); + refreshMcpHub(); + } else { + showToast('保存失败:' + (res.error || '未知错误'), 'error'); + } + } catch (err) { + showToast('保存出错:' + err.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = origText; + } +} diff --git a/app-tool-status.js b/app-tool-status.js new file mode 100644 index 00000000..bb076c0e --- /dev/null +++ b/app-tool-status.js @@ -0,0 +1,1138 @@ +// Extracted domain module. Loaded before app.js; functions resolve shared globals at call time. + +function setProxyStatusElement(element, color, text) { + clearElement(element); + const dot = document.createElement('span'); + dot.className = `status-dot ${color}`; + element.append(dot, document.createTextNode(text)); +} + +function isStartupStatusSettling(options = {}) { + if (options.startup === false) return false; + if (config && config.appSettings && config.appSettings.autoStartProxy === false) return false; + return performance.now() - startupStatusStartedAt < STARTUP_STATUS_GRACE_MS; +} + +function renderStartupPortDiagnostics() { + const target = $('heroPortDiagnostics'); + if (target) target.textContent = '端口诊断:等待管理端口启动...'; +} + +function proxyStatusOutputLooksRunning(output) { + const text = String(output || ''); + return ( + /\bRunning\b/i.test(text) || + /\bPID\s*:?\s*\d+\b/i.test(text) || + /Proxy already running/i.test(text) || + /Address:\s*https?:\/\//i.test(text) + ); +} + +function proxyStatusPidFromOutput(output) { + const match = String(output || '').match(/\bPID\s*:?\s*(\d+)\b/i); + return match ? match[1] : ''; +} + +function scheduleStartupProxyRetry() { + if (startupProxyRetryTimer) return; + startupProxyRetryTimer = setTimeout(() => { + startupProxyRetryTimer = null; + if (!isStartupStatusSettling({ startup: true })) return; + lastStatusRefresh = 0; + refreshProxyStatus({ startup: true }).catch((err) => + console.error('startup refreshProxyStatus retry error:', err) + ); + }, STARTUP_STATUS_RETRY_MS); +} + +function deferStartupProxyFailure(statusElement) { + setProxyStatusElement(statusElement, 'amber', '启动中...'); + renderStartupPortDiagnostics(); + scheduleStartupProxyRetry(); +} + +async function refreshProxyStatus(options = {}) { + const now = Date.now(); + const passiveTtl = isMonitorVisible() ? 7000 : 30000; + if (statusRefreshInFlight || (options.passive && now - lastStatusRefresh < passiveTtl)) return; + const finishPerf = perfStart('refresh.proxyStatus'); + statusRefreshInFlight = true; + lastStatusRefresh = now; + const statusElement = $('heroProxyStatus'); + try { + const health = await checkProxyHealth(); + if (health) { + const pid = health.pid ? ` (PID: ${health.pid})` : ''; + setProxyStatusElement(statusElement, 'green', `运行中${pid}`); + renderPortDiagnostics(health); + refreshCertStatusAfterProxyOnline(options); + return; + } + + const result = await window.proxyManager.command('status'); + const output = result.output || ''; + if (proxyStatusOutputLooksRunning(output)) { + const pidValue = proxyStatusPidFromOutput(output); + const pid = pidValue ? ` (PID: ${pidValue})` : ''; + setProxyStatusElement(statusElement, 'green', `运行中${pid}`); + renderPortDiagnostics(null); + refreshCertStatusAfterProxyOnline(options); + return; + } + if (output.trim()) { + if (isStartupStatusSettling(options)) { + deferStartupProxyFailure(statusElement); + return; + } + setProxyStatusElement(statusElement, 'amber', '未运行'); + renderPortDiagnostics(null); + return; + } + if (isStartupStatusSettling(options)) { + deferStartupProxyFailure(statusElement); + return; + } + setProxyStatusElement(statusElement, 'red', '状态未知'); + renderPortDiagnostics(null); + } catch (err) { + // command 调用本身失败时,也用 HTTP 探活兜底 + const health = await checkProxyHealth(); + if (health) { + const pid = health.pid ? ` (PID: ${health.pid})` : ''; + setProxyStatusElement(statusElement, 'green', `运行中${pid}`); + renderPortDiagnostics(health); + refreshCertStatusAfterProxyOnline(options); + return; + } + if (isStartupStatusSettling(options)) { + deferStartupProxyFailure(statusElement); + return; + } + setProxyStatusElement(statusElement, 'red', '检查失败'); + renderPortDiagnostics(null); + } finally { + statusRefreshInFlight = false; + finishPerf(); + } +} + +function renderPortDiagnostics(health) { + const target = $('heroPortDiagnostics'); + if (!target) return; + if (!health || health.appId !== 'https-proxy-manager' || !health.ports) { + target.textContent = '端口诊断:代理未运行或管理端口不可用'; + return; + } + const entries = Array.isArray(health.ports.entries) ? health.ports.entries : []; + const httpsCount = entries.filter((entry) => entry && entry.tls !== false).length; + const httpCount = entries.filter((entry) => entry && entry.tls === false).length; + const entryText = + httpCount > 0 + ? `${httpsCount} 个 HTTPS 入口 + ${httpCount} 个 HTTP 备用入口在线` + : `${httpsCount} 个入口在线`; + const management = health.ports.management ? ` · 管理 ${health.ports.management}` : ''; + target.textContent = `端口诊断:${entryText}${management}`; +} + +async function refreshClaudeDesktopStatus() { + if (!window.proxyManager.claudeDesktopStatus) return; + const badgeEl = $('toolBadge-claude-desktop'); + const dotEl = $('claudeDesktopTitleDot'); + try { + const status = await window.proxyManager.claudeDesktopStatus(); + if (!status.supported) { + $('claudeDesktopStatus').textContent = + `当前平台不可写入 Claude Desktop 配置:${status.error || ''}`; + if (badgeEl) { + badgeEl.textContent = '不支持'; + badgeEl.className = 'badge error'; + } + if (dotEl) dotEl.className = 'status-dot red'; + return; + } + const configured = status.configured ? '已写入 3P profile' : '未写入'; + const actual = status.actualBaseUrl || status.gatewayUrl || '无'; + const expected = status.expectedBaseUrl || '无'; + const desiredMode = status.desiredMode ? `;选择模式:${status.desiredMode}` : ''; + const matchText = status.matchesExpected === false ? ';状态:需重新写入' : ''; + const pathText = + status.profilePath || status.configPath + ? `\nProfile:${status.profilePath || status.configPath}` + : ''; + const evidence = status.clientEvidence || {}; + const activeCode = evidence.activeCodeProcess || {}; + const latestSession = evidence.latestCodeSession || {}; + const clientText = evidence.processCount + ? `\n客户端:Claude PID ${evidence.mainWindowPid || '未知'};Code 模型:${activeCode.model || latestSession.model || '未检测'};CLI Session:${activeCode.cliSessionId || latestSession.cliSessionId || '未检测'}` + : ''; + $('claudeDesktopStatus').textContent = + `${configured};模式:${status.mode}${desiredMode};当前 URL:${actual};预期 URL:${expected}${matchText}${pathText}${clientText}`; + + // Update badge and title dot + if (badgeEl) { + if (status.configured) { + if (status.mode === 'proxy') { + badgeEl.textContent = '已代理'; + badgeEl.className = 'badge active'; + } else if (status.mode === 'direct') { + badgeEl.textContent = '直连'; + badgeEl.className = 'badge direct'; + } else { + badgeEl.textContent = '官方'; + badgeEl.className = 'badge official'; + } + } else { + badgeEl.textContent = '未接管'; + badgeEl.className = 'badge official'; + } + } + if (dotEl) { + if (status.configured && status.mode === 'proxy') { + dotEl.className = 'status-dot green'; + } else if (status.configured && status.mode === 'direct') { + dotEl.className = 'status-dot amber'; + } else { + dotEl.className = 'status-dot'; + } + } + } catch (err) { + $('claudeDesktopStatus').textContent = + `Claude Desktop 状态检查失败:${err && err.message ? err.message : err}`; + if (badgeEl) { + badgeEl.textContent = '失败'; + badgeEl.className = 'badge error'; + } + if (dotEl) dotEl.className = 'status-dot red'; + } +} + +async function refreshClaudeCodeStatus() { + if (!window.proxyManager.claudeCodeStatus) return; + const badgeEl = $('toolBadge-claude-code'); + const dotEl = $('claudeCodeTitleDot'); + try { + const status = await window.proxyManager.claudeCodeStatus(); + if (!status.exists) { + $('claudeCodeStatus').textContent = + `未检测到 Claude Code 配置文件 (路径: ${status.configPath || ''})`; + $('claudeCodeStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '未检测'; + badgeEl.className = 'badge official'; + } + if (dotEl) dotEl.className = 'status-dot'; + return; + } + if (status.baseUrl) { + $('claudeCodeStatus').textContent = + `已配置本地代理;网关地址: ${status.baseUrl},配置路径: ${status.configPath}`; + $('claudeCodeStatus').className = 'notice green'; + if (badgeEl) { + badgeEl.textContent = '已代理'; + badgeEl.className = 'badge active'; + } + if (dotEl) dotEl.className = 'status-dot green'; + } else { + $('claudeCodeStatus').textContent = `官方直连/已启用登录;配置路径: ${status.configPath}`; + $('claudeCodeStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '直连'; + badgeEl.className = 'badge direct'; + } + if (dotEl) dotEl.className = 'status-dot amber'; + } + } catch (err) { + $('claudeCodeStatus').textContent = + `Claude Code 状态检查失败:${err && err.message ? err.message : err}`; + $('claudeCodeStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '失败'; + badgeEl.className = 'badge error'; + } + if (dotEl) dotEl.className = 'status-dot red'; + } +} + +function formatTraeBridgeUrl(status) { + const url = status && (status.bridgeUrl || status.url || status.healthUrl); + if (!url) return 'http://127.0.0.1:17861'; + return String(url).replace(/\/health$/, ''); +} + +function formatTraeCommand(status, key, fallback = '未发现') { + const value = status && status[key]; + return value ? String(value) : fallback; +} + +async function refreshTraeBridgeStatus() { + const badgeEl = $('toolBadge-trae-bridge'); + const dotEl = $('traeBridgeTitleDot'); + const statusEl = $('traeBridgeStatus'); + if (!statusEl) return; + if (!window.proxyManager.traeBridgeStatus) { + statusEl.textContent = '当前版本缺少 Trae Bridge 状态接口。'; + statusEl.className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '不可用'; + badgeEl.className = 'badge error'; + } + if (dotEl) dotEl.className = 'status-dot red'; + return; + } + try { + const status = await window.proxyManager.traeBridgeStatus(); + const reachable = + status && + status.ok !== false && + (status.statusCode === 200 || + Boolean(status.command) || + Boolean(status.waitCommand) || + Boolean(status.streamCommand) || + Boolean(status.rawStreamCommand)); + if (!reachable) { + const reason = status && (status.error || status.message || status.statusCode); + statusEl.textContent = `Trae Bridge 未连接;本地桥接服务通常位于 http://127.0.0.1:17861。${reason ? `原因:${reason}` : '请确认 Trae 已启动并加载扩展。'}`; + statusEl.className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '未连接'; + badgeEl.className = 'badge official'; + } + if (dotEl) dotEl.className = 'status-dot amber'; + return; + } + + const bridgeUrl = formatTraeBridgeUrl(status); + const waitCommand = formatTraeCommand( + status, + 'waitCommand', + formatTraeCommand(status, 'command') + ); + const streamCommand = formatTraeCommand(status, 'streamCommand'); + const rawStreamCommand = formatTraeCommand(status, 'rawStreamCommand'); + const discoveredCount = Number.isFinite(status.discoveredCommandCount) + ? status.discoveredCommandCount + : Array.isArray(status.discoveredCommands) + ? status.discoveredCommands.length + : 0; + const rawAvailable = rawStreamCommand !== '未发现'; + const rawText = rawAvailable ? `Raw 调试流:${rawStreamCommand}` : 'Raw 调试流:未发现'; + + statusEl.textContent = `Trae Bridge 已连接;地址:${bridgeUrl};等待命令:${waitCommand};流式命令:${streamCommand};${rawText};发现命令:${discoveredCount} 个。`; + statusEl.className = 'notice green'; + if (badgeEl) { + badgeEl.textContent = rawAvailable ? '已连接' : '可用'; + badgeEl.className = 'badge active'; + } + if (dotEl) dotEl.className = 'status-dot green'; + refreshTraeDebugSettings(); + } catch (err) { + statusEl.textContent = `Trae Bridge 状态检查失败:${err && err.message ? err.message : err}`; + statusEl.className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '失败'; + badgeEl.className = 'badge error'; + } + if (dotEl) dotEl.className = 'status-dot red'; + } +} + +let lastTraeNativeDebugCommands = [ + 'python scripts\\trae-frida-scan.py', + 'python scripts\\trae-frida-native-probe.py --pid --duration 45', + 'python scripts\\trae-frida-native-probe.py --pid --duration 45', +].join('\n'); + +function renderTraeDebugEvents(events) { + const target = $('traeDebugEvents'); + if (!target) return; + clearElement(target); + if (!Array.isArray(events) || events.length === 0) { + const empty = document.createElement('div'); + empty.className = 'muted'; + empty.textContent = '暂无过程事件。先发起一次 Trae 请求,或启用 Raw Stream Patch 后再刷新。'; + target.appendChild(empty); + return; + } + events + .slice(-40) + .reverse() + .forEach((event) => { + const source = event.source || 'trae'; + const kind = event.kind || event.event || 'event'; + const message = + event.message || + [ + kind, + event.toolName ? `tool=${event.toolName}` : '', + event.toolStatus ? `status=${event.toolStatus}` : '', + event.bodyLen == null ? '' : `body=${event.bodyLen}`, + ] + .filter(Boolean) + .join(' '); + const item = document.createElement('div'); + item.className = 'trae-debug-event'; + const sourceEl = document.createElement('div'); + sourceEl.className = 'trae-debug-event-source'; + sourceEl.textContent = source; + const messageEl = document.createElement('div'); + messageEl.className = 'trae-debug-event-message'; + messageEl.textContent = message; + item.append(sourceEl, messageEl); + target.appendChild(item); + }); +} + +function formatTraeNativeCommandBlock(status, scanSummary) { + const commands = status && status.commands ? status.commands : {}; + const aiPid = scanSummary && scanSummary.aiAgentPid; + const ahaPid = scanSummary && scanSummary.ahaNetPid; + return [ + commands.installFrida || 'python -m pip install --user frida-tools', + commands.scan || 'python scripts\\trae-frida-scan.py', + aiPid + ? `python scripts\\trae-frida-native-probe.py --pid ${aiPid} --duration 45` + : commands.aiAgentHook || + 'python scripts\\trae-frida-native-probe.py --pid --duration 45', + ahaPid + ? `python scripts\\trae-frida-native-probe.py --pid ${ahaPid} --duration 45` + : commands.ahaNetHook || + 'python scripts\\trae-frida-native-probe.py --pid --duration 45', + ].join('\n'); +} + +async function refreshTraeDebugEvents() { + const summaryEl = $('traeDebugSummary'); + if (!summaryEl || !window.proxyManager.traeDebugEvents) return; + summaryEl.textContent = '正在读取 Trae 过程事件...'; + summaryEl.className = 'notice'; + try { + const [eventsResult, nativeStatus] = await Promise.all([ + window.proxyManager.traeDebugEvents(), + window.proxyManager.traeDebugNativeStatus + ? window.proxyManager.traeDebugNativeStatus() + : Promise.resolve(null), + ]); + if (nativeStatus && nativeStatus.commands) { + lastTraeNativeDebugCommands = formatTraeNativeCommandBlock(nativeStatus); + } + if (!eventsResult || eventsResult.ok === false) { + summaryEl.textContent = `Trae Debug 事件读取失败:${eventsResult && eventsResult.error ? eventsResult.error : '未知错误'}`; + summaryEl.className = 'notice'; + renderTraeDebugEvents([]); + return; + } + const rawCount = + eventsResult.raw && Number.isFinite(eventsResult.raw.rawEventCount) + ? eventsResult.raw.rawEventCount + : 0; + const logCount = Number.isFinite(eventsResult.logEventCount) ? eventsResult.logEventCount : 0; + const eventCount = Array.isArray(eventsResult.events) ? eventsResult.events.length : 0; + summaryEl.textContent = `已聚合 ${eventCount} 条安全过程事件;Raw ${rawCount} 条,日志摘要 ${logCount} 条。`; + summaryEl.className = 'notice green'; + renderTraeDebugEvents(eventsResult.events || []); + } catch (err) { + summaryEl.textContent = `Trae Debug 事件读取失败:${err && err.message ? err.message : err}`; + summaryEl.className = 'notice'; + } +} + +function applyTraeDebugSettingsToUi(settings) { + const rawEl = $('traeRawCommentsEnabled'); + const visibleEl = $('traeApiVisibleProgressEnabled'); + const claudeProcessStyleEl = $('traeClaudeProcessStyle'); + const logEl = $('traeLogProgressEnabled'); + if (rawEl) rawEl.checked = Boolean(settings && settings.rawCommentsEnabled); + if (visibleEl) visibleEl.checked = Boolean(settings && settings.apiVisibleProgressEnabled); + if (claudeProcessStyleEl) + claudeProcessStyleEl.value = + settings && settings.claudeProcessProgressEnabled + ? settings.claudeProcessStyle || 'thinking' + : ''; + if (logEl) logEl.checked = settings ? settings.liveLogProgress !== false : true; +} + +function collectTraeDebugSettingsFromUi() { + const visible = Boolean( + $('traeApiVisibleProgressEnabled') && $('traeApiVisibleProgressEnabled').checked + ); + return { + rawCommentsEnabled: + visible || Boolean($('traeRawCommentsEnabled') && $('traeRawCommentsEnabled').checked), + apiVisibleProgressEnabled: visible, + claudeProcessStyle: + $('traeClaudeProcessStyle') && $('traeClaudeProcessStyle').value + ? $('traeClaudeProcessStyle').value + : '', + liveLogProgress: !( + $('traeLogProgressEnabled') && $('traeLogProgressEnabled').checked === false + ), + }; +} + +async function refreshTraeDebugSettings() { + if (!window.proxyManager.traeDebugSettings) return; + try { + const settings = await window.proxyManager.traeDebugSettings(); + if (settings && settings.ok !== false) applyTraeDebugSettingsToUi(settings); + } catch (err) { + // Debug switches are optional; the status banner will show bridge health separately. + } +} + +async function saveTraeDebugSettingsFromUi() { + const summaryEl = $('traeDebugSummary'); + if (!window.proxyManager.saveTraeDebugSettings) return; + const payload = collectTraeDebugSettingsFromUi(); + if ($('traeRawCommentsEnabled')) $('traeRawCommentsEnabled').checked = payload.rawCommentsEnabled; + if (summaryEl) { + summaryEl.textContent = '正在保存 Trae Debug 开关...'; + summaryEl.className = 'notice'; + } + try { + const settings = await window.proxyManager.saveTraeDebugSettings(payload); + if (!settings || settings.ok === false) + throw new Error((settings && settings.error) || '保存失败'); + applyTraeDebugSettingsToUi(settings); + const msg = settings.claudeProcessProgressEnabled + ? settings.claudeProcessStyle === 'text' + ? 'Trae Claude 文本备用过程块已开启;后续 Claude 流式 API 会把 Trae 过程写成独立文本块。' + : 'Trae Claude Thinking 过程块已开启;后续 Claude 流式 API 会尝试把 Trae 过程写入 thinking 区域。' + : settings.apiVisibleProgressEnabled + ? 'Trae API 可见过程已开启;后续流式 API 会把安全过程摘要写入 assistant 正文。' + : 'Trae Debug 开关已保存;API 正文不再注入可见过程摘要。'; + if (summaryEl) { + summaryEl.textContent = msg; + summaryEl.className = + settings.apiVisibleProgressEnabled || settings.claudeProcessProgressEnabled + ? 'notice green' + : 'notice'; + } + $('output').textContent = msg; + showToast(msg, 'success'); + } catch (err) { + const msg = `保存 Trae Debug 开关失败:${err && err.message ? err.message : err}`; + if (summaryEl) summaryEl.textContent = msg; + showToast(msg, 'error'); + } +} + +function formatTraeClaudeProcessSelfTest(result) { + if (!result || result.ok === false) { + return `Claude 过程输出自检失败:${result && result.error ? result.error : '未知错误'}`; + } + const summary = result.summary || {}; + const lines = [ + `pass=${Boolean(result.pass)} status=${result.statusCode || ''} source=${result.source || ''}`, + `model=${(result.request && result.request.model) || ''} expectedStyle=${result.expectedStyle || ''}`, + `debugMode=${(result.settings && result.settings.rawProgressMode) || ''} debugStyle=${(result.settings && result.settings.claudeProcessStyle) || ''}`, + `endpoint=${result.endpoint || ''}`, + `thinking_delta=${summary.thinkingDeltaCount || 0} signature_delta=${summary.signatureDeltaCount || 0} text_delta=${summary.textDeltaCount || 0}`, + `hasTraeProcess=${Boolean(summary.hasTraeProcessText)} planItem=${Boolean(summary.hasTraePlanItem)} timing=${Boolean(summary.hasTraeTiming)} messageStop=${Boolean(summary.hasDone)}`, + result.hint || '', + ]; + return lines.filter(Boolean).join('\n'); +} + +async function runClaudeDesktopProcessSelfTest({ + summaryEl, + outputEl, + runningText, + successText, +} = {}) { + if (!window.proxyManager.traeDebugClaudeProcessSelfTest) return; + if (summaryEl) { + summaryEl.textContent = + runningText || + '正在验证 Claude Desktop 过程输出;会通过 Desktop Gateway 发起一条短 Trae 流式请求...'; + summaryEl.className = 'notice'; + } + if (outputEl) outputEl.textContent = 'Claude 过程输出自检运行中...'; + try { + const result = await window.proxyManager.traeDebugClaudeProcessSelfTest({ + useClaudeDesktopGateway: true, + prompt: '请只回复 OK。', + timeoutMs: 90000, + }); + const text = formatTraeClaudeProcessSelfTest(result); + if (outputEl) outputEl.textContent = text; + if (summaryEl) { + summaryEl.textContent = + result && result.pass + ? successText || + 'Claude Desktop 过程输出自检通过;Desktop Gateway SSE 已包含 Trae 过程事件。' + : text; + summaryEl.className = result && result.pass ? 'notice green' : 'notice'; + } + showToast( + result && result.pass ? 'Claude 过程输出自检通过。' : 'Claude 过程输出自检未通过。', + result && result.pass ? 'success' : 'error' + ); + } catch (err) { + const msg = `Claude 过程输出自检失败:${err && err.message ? err.message : err}`; + if (summaryEl) summaryEl.textContent = msg; + if (outputEl) outputEl.textContent = msg; + showToast(msg, 'error'); + } +} + +async function testTraeClaudeProcessOutput() { + await runClaudeDesktopProcessSelfTest({ + summaryEl: $('traeDebugSummary'), + outputEl: $('traeNativeDeepDebugOutput') || $('traeNativeDebugOutput'), + }); +} + +async function testClaudeDesktopProcessOutput() { + await runClaudeDesktopProcessSelfTest({ + summaryEl: $('claudeDesktopStatus'), + outputEl: $('output'), + runningText: + '正在验证 Claude Desktop 过程显示;会通过当前 Desktop Gateway 跑一条短 Trae 流式请求...', + successText: '过程显示自检通过;Claude Desktop Gateway SSE 已包含 Trae 过程文本。', + }); +} + +function firstClaudeDesktopTraeRouteId() { + ensureClaudeDesktop(); + for (const model of config.claudeDesktop.models || []) { + const resolved = resolveClaudeDesktopModel(model); + const profile = config.profiles.find((item) => item.id === resolved.profileId) || {}; + const target = String(profile.targetUrl || '').toLowerCase(); + if ( + profile.protocol === 'trae' || + resolved.profileId === 'trae-official' || + target.includes('coresg-normal.trae.ai') + ) { + return resolved.routeId; + } + } + const first = config.claudeDesktop.models && config.claudeDesktop.models[0]; + return first ? routeIdOfClaudeDesktopModel(first) : ''; +} + +async function copyClaudeDesktopProcessPrompt() { + let copied = false; + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(CLAUDE_DESKTOP_PROCESS_VERIFICATION_PROMPT); + copied = true; + } + } catch (err) { + copied = false; + } + const routeId = firstClaudeDesktopTraeRouteId(); + const msg = [ + copied ? 'Claude Desktop 验证提示已复制。' : 'Claude Desktop 验证提示如下。', + routeId ? `在 Claude Desktop 里选择模型 ${routeId},粘贴后发送。` : '', + '如果窗口里出现 [Trae plan_item]、[Trae timing] 或类似过程文本,就说明客户端可见链路已打通。', + '', + CLAUDE_DESKTOP_PROCESS_VERIFICATION_PROMPT, + ] + .filter(Boolean) + .join('\n'); + $('output').textContent = msg; + showDiagnosticsView('outputPanel'); + showToast(copied ? 'Claude Desktop 验证提示已复制' : '已显示 Claude Desktop 验证提示', 'success'); +} + +async function scanTraeNativeDebug() { + const outputEl = $('traeNativeDebugOutput'); + const summaryEl = $('traeDebugSummary'); + if (!outputEl || !window.proxyManager.traeDebugNativeScan) return; + outputEl.textContent = '正在扫描 Trae Native PID...'; + if (summaryEl) { + summaryEl.textContent = 'Native 扫描运行中;这是一次性 Frida attach 扫描。'; + summaryEl.className = 'notice'; + } + try { + const [status, scan] = await Promise.all([ + window.proxyManager.traeDebugNativeStatus + ? window.proxyManager.traeDebugNativeStatus() + : Promise.resolve(null), + window.proxyManager.traeDebugNativeScan(), + ]); + const scanSummary = scan && scan.summary ? scan.summary : {}; + if (status && status.commands) { + lastTraeNativeDebugCommands = formatTraeNativeCommandBlock(status, scanSummary); + } + if (scan && scan.running) { + outputEl.textContent = [ + 'Native 扫描已启动,后台运行中。', + scan.summary && scan.summary.ahaNetPid + ? `已缓存 aha_net PID: ${scan.summary.ahaNetPid}` + : '等待扫描结果...', + '', + scan.output || '', + ].join('\n'); + if (summaryEl) { + summaryEl.textContent = 'Native PID 扫描后台运行中;稍后会自动刷新。'; + summaryEl.className = 'notice'; + } + setTimeout(scanTraeNativeDebug, 13000); + return; + } + const lines = [ + scan && scan.ok + ? 'Native 扫描完成。' + : `Native 扫描失败:${(scan && (scan.error || scan.status)) || '未知错误'}`, + `ai_agent PID: ${scanSummary.aiAgentPid || '未发现'}`, + `aha_net PID: ${scanSummary.ahaNetPid || '未发现'}`, + '', + '候选进程:', + ...(Array.isArray(scanSummary.candidates) && scanSummary.candidates.length + ? scanSummary.candidates.map((item) => `- ${item.pid}: ${item.modules.join(', ')}`) + : ['- 未发现 native 候选']), + '', + '脱敏扫描输出:', + scan && scan.output ? scan.output : '', + ]; + outputEl.textContent = lines.join('\n'); + if (summaryEl) { + summaryEl.textContent = + scan && scan.ok + ? 'Native PID 扫描完成;可复制 Hook 命令进行短时本机 Debug。' + : 'Native PID 扫描未完成;可检查 Frida/Python 状态。'; + summaryEl.className = scan && scan.ok ? 'notice green' : 'notice'; + } + } catch (err) { + outputEl.textContent = `Native 扫描失败:${err && err.message ? err.message : err}`; + if (summaryEl) summaryEl.textContent = outputEl.textContent; + } +} + +function formatTraeNativeDeepOutput(result) { + if (!result || result.ok === false) { + return `Native 深入事件读取失败:${result && result.error ? result.error : '未知错误'}`; + } + const counts = result.counts && typeof result.counts === 'object' ? result.counts : {}; + const countLines = Object.keys(counts) + .sort() + .map((key) => `- ${key}: ${counts[key]}`); + const events = Array.isArray(result.events) ? result.events : []; + const eventLines = events.slice(-36).map((event) => { + const keys = + Array.isArray(event.keys) && event.keys.length ? ` keys=${event.keys.join(',')}` : ''; + const sse = + Array.isArray(event.sseEventNames) && event.sseEventNames.length + ? ` sse=${event.sseEventNames.join(',')}` + : ''; + const claude = + Array.isArray(event.sseSemantics) && event.sseSemantics.length + ? ` claude=${event.sseSemantics + .map((item) => item.claudeCandidate) + .filter(Boolean) + .filter((item, index, arr) => arr.indexOf(item) === index) + .join(',')}` + : ''; + const signals = + event.contains && typeof event.contains === 'object' + ? Object.keys(event.contains) + .filter((key) => event.contains[key]) + .join(',') + : ''; + return [ + `- ${event.kind || event.event || 'event'}`, + event.urlPath ? ` url=${event.urlPath}` : '', + event.bytesRead == null ? '' : ` bytes=${event.bytesRead}`, + sse, + claude, + signals ? ` signals=${signals}` : '', + keys, + ] + .filter(Boolean) + .join(''); + }); + return [ + `状态: ${result.status || 'unknown'}${result.running ? ' (running)' : ''}`, + `PID: ${result.pid || '未选择'},模式: ${result.mode || '未启动'},事件: ${result.eventCount || 0}`, + result.error ? `错误: ${result.error}` : '', + result.warning ? `提示: ${result.warning}` : '', + '', + '计数:', + ...(countLines.length ? countLines : ['- 暂无']), + '', + '最近事件:', + ...(eventLines.length ? eventLines : ['- 暂无;启动后发起一次 Trae API 请求再刷新。']), + ] + .filter((line) => line !== '') + .join('\n'); +} + +function waitMs(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getTraeAhaPidFromScan(scan) { + if (scan && scan.summary && scan.summary.ahaNetPid) return Number(scan.summary.ahaNetPid); + const candidates = + scan && scan.summary && Array.isArray(scan.summary.candidates) ? scan.summary.candidates : []; + const candidate = candidates.find( + (item) => Array.isArray(item.modules) && item.modules.includes('aha_net.dll') + ); + return candidate ? Number(candidate.pid) : 0; +} + +async function ensureTraeNativePidReady(outputEl, summaryEl) { + if (!window.proxyManager.traeDebugNativeScan) { + throw new Error('Trae Native PID 扫描接口不可用'); + } + let lastScan = null; + for (let attempt = 0; attempt < 16; attempt += 1) { + lastScan = await window.proxyManager.traeDebugNativeScan(); + const pid = getTraeAhaPidFromScan(lastScan); + if (pid) return { pid, scan: lastScan }; + const running = lastScan && lastScan.running; + const line = running + ? `正在自动扫描 Native PID... ${attempt + 1}/16` + : `正在准备 Native PID... ${attempt + 1}/16`; + if (outputEl) { + outputEl.textContent = [ + line, + lastScan && lastScan.output ? lastScan.output : '等待扫描结果...', + ].join('\n\n'); + } + if (summaryEl) { + summaryEl.textContent = line; + summaryEl.className = 'notice'; + } + await waitMs(attempt < 2 ? 1800 : 2500); + } + const error = lastScan && (lastScan.error || lastScan.status); + throw new Error( + error ? `Native PID 扫描未完成:${error}` : '未发现 Trae aha_net PID,请确认 Trae 正在运行' + ); +} + +async function refreshTraeNativeDeepDebug() { + const outputEl = $('traeNativeDeepDebugOutput'); + const summaryEl = $('traeDebugSummary'); + if (!outputEl || !window.proxyManager.traeDebugNativeDeepEvents) return null; + try { + const result = await window.proxyManager.traeDebugNativeDeepEvents(); + outputEl.textContent = formatTraeNativeDeepOutput(result); + if (summaryEl && result && result.ok !== false) { + summaryEl.textContent = result.running + ? `Native 深入探针运行中;已捕获 ${result.eventCount || 0} 条脱敏结构事件。` + : `Native 深入探针状态:${result.status || 'idle'};已捕获 ${result.eventCount || 0} 条脱敏结构事件。`; + summaryEl.className = result.eventCount ? 'notice green' : 'notice'; + } + return result; + } catch (err) { + outputEl.textContent = `Native 深入事件读取失败:${err && err.message ? err.message : err}`; + if (summaryEl) summaryEl.textContent = outputEl.textContent; + return null; + } +} + +async function startTraeNativeDeepDebug() { + const outputEl = $('traeNativeDeepDebugOutput'); + const summaryEl = $('traeDebugSummary'); + if (!window.proxyManager.startTraeDebugNativeDeep) return; + if (outputEl) outputEl.textContent = '正在一键启用 Native 深入探针...'; + if (summaryEl) { + summaryEl.textContent = + '正在自动扫描 PID 并启动深入探针;这是短时 Frida attach,只用于本机诊断。'; + summaryEl.className = 'notice'; + } + try { + const ready = await ensureTraeNativePidReady(outputEl, summaryEl); + if (outputEl) outputEl.textContent = `已找到 aha_net PID ${ready.pid},正在启动深入探针...`; + const result = await window.proxyManager.startTraeDebugNativeDeep({ + pid: ready.pid, + durationSeconds: 60, + maxPreview: 360, + }); + if (!result || result.ok === false) { + const msg = `Native 深入探针启动失败:${result && result.error ? result.error : '未知错误'}`; + if (outputEl) outputEl.textContent = msg; + if (summaryEl) summaryEl.textContent = msg; + showToast(msg, 'error'); + return; + } + if (outputEl) outputEl.textContent = formatTraeNativeDeepOutput(result); + const msg = 'Native 深入探针已启动;现在发起一次 Trae API 请求,然后刷新深入事件。'; + if (summaryEl) { + summaryEl.textContent = msg; + summaryEl.className = 'notice green'; + } + showToast(msg, 'success'); + setTimeout(refreshTraeNativeDeepDebug, 2500); + } catch (err) { + const msg = `Native 深入探针启动失败:${err && err.message ? err.message : err}`; + if (outputEl) outputEl.textContent = msg; + if (summaryEl) summaryEl.textContent = msg; + showToast(msg, 'error'); + } +} + +async function stopTraeNativeDeepDebug() { + if (!window.proxyManager.stopTraeDebugNativeDeep) return; + try { + const result = await window.proxyManager.stopTraeDebugNativeDeep(); + const outputEl = $('traeNativeDeepDebugOutput'); + if (outputEl) outputEl.textContent = formatTraeNativeDeepOutput(result); + showToast('Native 深入探针停止请求已发送。', 'success'); + } catch (err) { + showToast(`Native 深入探针停止失败:${err && err.message ? err.message : err}`, 'error'); + } +} + +async function refreshAntigravityCliStatus() { + if (!window.proxyManager.antigravityCliStatus) return; + const badgeEl = $('toolBadge-antigravity-cli'); + const dotEl = $('antigravityCliTitleDot'); + try { + const status = await window.proxyManager.antigravityCliStatus(); + if (!status.exists) { + $('antigravityCliStatus').textContent = + `未检测到 Antigravity CLI 配置文件 (路径: ${status.configPath || ''})`; + $('antigravityCliStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '未检测'; + badgeEl.className = 'badge official'; + } + if (dotEl) dotEl.className = 'status-dot'; + return; + } + if (status.baseUrl) { + $('antigravityCliStatus').textContent = + `已配置本地代理;网关地址: ${status.baseUrl},配置路径: ${status.configPath}`; + $('antigravityCliStatus').className = 'notice green'; + if (badgeEl) { + badgeEl.textContent = '已代理'; + badgeEl.className = 'badge active'; + } + if (dotEl) dotEl.className = 'status-dot green'; + } else { + $('antigravityCliStatus').textContent = `官方直连/未配置;配置路径: ${status.configPath}`; + $('antigravityCliStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '直连'; + badgeEl.className = 'badge direct'; + } + if (dotEl) dotEl.className = 'status-dot amber'; + } + } catch (err) { + $('antigravityCliStatus').textContent = + `Antigravity CLI 状态检查失败:${err && err.message ? err.message : err}`; + $('antigravityCliStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '失败'; + badgeEl.className = 'badge error'; + } + if (dotEl) dotEl.className = 'status-dot red'; + } +} + +async function refreshGeminiCliStatus() { + if (!window.proxyManager.geminiCliStatus) return; + const badgeEl = $('toolBadge-gemini-cli'); + const dotEl = $('geminiCliTitleDot'); + try { + const status = await window.proxyManager.geminiCliStatus(); + if (!status.exists) { + $('geminiCliStatus').textContent = + `未检测到 Google Gemini CLI 配置文件 (路径: ${status.configPath || ''})`; + $('geminiCliStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '未检测'; + badgeEl.className = 'badge official'; + } + if (dotEl) dotEl.className = 'status-dot'; + return; + } + if (status.apiEndpoint || status.envBaseUrl) { + const ep = status.apiEndpoint || status.envBaseUrl; + $('geminiCliStatus').textContent = + `已配置本地代理;网关地址: ${ep},配置路径: ${status.configPath}`; + $('geminiCliStatus').className = 'notice green'; + if (badgeEl) { + badgeEl.textContent = '已代理'; + badgeEl.className = 'badge active'; + } + if (dotEl) dotEl.className = 'status-dot green'; + } else { + $('geminiCliStatus').textContent = `官方直连/已启用认证;配置路径: ${status.configPath}`; + $('geminiCliStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '直连'; + badgeEl.className = 'badge direct'; + } + if (dotEl) dotEl.className = 'status-dot amber'; + } + } catch (err) { + $('geminiCliStatus').textContent = + `Google Gemini CLI 状态检查失败:${err && err.message ? err.message : err}`; + $('geminiCliStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '失败'; + badgeEl.className = 'badge error'; + } + if (dotEl) dotEl.className = 'status-dot red'; + } +} + +function formatCodexAuthSummary(status) { + const labels = { + chatgpt: '官方登录', + api: 'API 登录', + mixed: '官方 token + 用户 API Key', + 'legacy-managed-api-key': '旧版代理占位 Key', + none: '未登录', + }; + const label = labels[status && status.authMode] || '未知'; + const notes = []; + if (status && status.authConflict) { + notes.push('Codex 运行时仍会二选一,建议切换前清理不需要的凭据'); + } + if (status && status.hasManagedApiKey) { + notes.push('检测到旧版代理占位 Key,恢复直连会自动清理'); + } + if (status && status.hasProxyManagedApiKey) { + notes.push('本地代理占位 Key'); + } + if (status && status.authMode !== 'chatgpt' && !status.chatGptBackupExists) { + notes.push('未备份官方登录'); + } + if (notes.length === 0 && status && status.authGuarded) { + notes.push('可一键切换登录态'); + } + return `;Codex 登录: ${label}${notes.length ? `(${notes.join(';')})` : ''}`; +} + +async function refreshCodexStatus() { + if (!window.proxyManager.codexStatus) return; + const badgeEl = $('toolBadge-codex-cli'); + const dotEl = $('codexTitleDot'); + try { + const status = await window.proxyManager.codexStatus(); + if (status && status.ok === false && status.error) throw new Error(status.error); + if (!status.exists) { + $('codexStatus').textContent = + `未检测到 Codex CLI 配置文件 (路径: ${status.configPath || ''})`; + $('codexStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '未检测'; + badgeEl.className = 'badge official'; + } + if (dotEl) dotEl.className = 'status-dot'; + return; + } + const auth = formatCodexAuthSummary(status); + if (status.baseUrl) { + const legacy = status.legacyBaseUrl && !status.openaiBaseUrl ? '(旧 base_url 写法)' : ''; + $('codexStatus').textContent = + `已配置本地代理;网关地址: ${status.baseUrl}${legacy}${auth},配置路径: ${status.configPath}`; + $('codexStatus').className = 'notice green'; + if (badgeEl) { + badgeEl.textContent = '已代理'; + badgeEl.className = 'badge active'; + } + if (dotEl) dotEl.className = 'status-dot green'; + } else { + $('codexStatus').textContent = `官方直连${auth};配置路径: ${status.configPath}`; + $('codexStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '直连'; + badgeEl.className = 'badge direct'; + } + if (dotEl) dotEl.className = 'status-dot amber'; + } + } catch (err) { + $('codexStatus').textContent = + `Codex CLI 状态检查失败:${err && err.message ? err.message : err}`; + $('codexStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '失败'; + badgeEl.className = 'badge error'; + } + if (dotEl) dotEl.className = 'status-dot red'; + } +} + +async function refreshCodexDesktopStatus() { + if (!window.proxyManager.codexDesktopStatus) return; + const badgeEl = $('toolBadge-codex-desktop'); + const dotEl = $('codexDesktopTitleDot'); + try { + const status = await window.proxyManager.codexDesktopStatus(); + if (status && status.ok === false && status.error) throw new Error(status.error); + if (!status.exists) { + const auth = formatCodexAuthSummary(status); + $('codexDesktopStatus').textContent = + `未检测到 Codex 用户配置 (路径: ${status.configPath || ''})${auth};应用代理会自动创建共享配置。`; + $('codexDesktopStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '未检测'; + badgeEl.className = 'badge official'; + } + if (dotEl) dotEl.className = 'status-dot'; + return; + } + const auth = formatCodexAuthSummary(status); + if (status.baseUrl) { + const restartText = status.requiresRestart ? ';重启 Codex Desktop 或新开会话后生效' : ''; + const legacy = + status.legacyBaseUrl && !status.openaiBaseUrl ? ';当前为旧 base_url 写法' : ''; + const catalog = + status.managedModelCatalog && status.modelCatalog && status.modelCatalog.exists + ? `;模型目录 ${status.modelCatalog.modelCount || 0} 个模型 / ${status.modelCatalog.routeCount || 0} 条路由` + : ''; + $('codexDesktopStatus').textContent = + `已接入共享 Codex 配置;网关地址: ${status.baseUrl}${legacy}${catalog}${auth},配置路径: ${status.configPath}${restartText}`; + $('codexDesktopStatus').className = 'notice green'; + if (badgeEl) { + badgeEl.textContent = '已代理'; + badgeEl.className = 'badge active'; + } + if (dotEl) dotEl.className = 'status-dot green'; + } else { + const catalog = + status.managedModelCatalog && status.modelCatalog && status.modelCatalog.exists + ? `;已生成模型目录 ${status.modelCatalog.modelCount || 0} 个模型` + : ''; + $('codexDesktopStatus').textContent = + `官方直连${catalog}${auth};配置路径: ${status.configPath}。Codex Desktop 通过共享 config.toml 和启动时模型目录接管。`; + $('codexDesktopStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '直连'; + badgeEl.className = 'badge direct'; + } + if (dotEl) dotEl.className = 'status-dot amber'; + } + } catch (err) { + $('codexDesktopStatus').textContent = + `Codex Desktop 状态检查失败:${err && err.message ? err.message : err}`; + $('codexDesktopStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '失败'; + badgeEl.className = 'badge error'; + } + if (dotEl) dotEl.className = 'status-dot red'; + } +} + +let toolStatusRefreshInFlight = null; +let lastToolStatusRefresh = 0; + +async function refreshAllToolStatuses(options = {}) { + const now = Date.now(); + if (!options.force && toolStatusRefreshInFlight) return toolStatusRefreshInFlight; + if (options.passive && now - lastToolStatusRefresh < 15000) return; + + const finishPerf = perfStart('refresh.toolStatuses'); + toolStatusRefreshInFlight = Promise.allSettled([ + refreshClaudeDesktopStatus(), + refreshClaudeCodeStatus(), + refreshTraeBridgeStatus(), + refreshAntigravityCliStatus(), + refreshGeminiCliStatus(), + refreshCodexStatus(), + refreshCodexDesktopStatus(), + ]).finally(() => { + lastToolStatusRefresh = Date.now(); + toolStatusRefreshInFlight = null; + finishPerf(); + }); + return toolStatusRefreshInFlight; +} diff --git a/eslint.config.js b/eslint.config.js index 1c85ecc9..0981bd1f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,4 +1,4 @@ -// ESLint 9 flat config for HTTPS-Proxy +// ESLint 10 flat config for HTTPS-Proxy // https://eslint.org/docs/latest/use/configure/configuration-files const js = require('@eslint/js'); @@ -26,6 +26,9 @@ module.exports = [ 'app-audit.js', 'app-cli.js', 'app-diagnostics.js', + 'app-tool-status.js', + 'app-environment.js', + 'app-mcp.js', 'electron-ui.html', 'test.json', '400', @@ -139,6 +142,13 @@ module.exports = [ 'comma-dangle': 'off', }, }, + { + files: ['lib/proxy/**/*.js', 'openai-proxy.js'], + ignores: ['lib/proxy/logger.js', 'lib/proxy/runtime-support.js'], + rules: { + 'no-console': 'error', + }, + }, { // Tests get a slightly looser rule set files: ['test/**/*.js', '**/*.test.js'], @@ -210,8 +220,8 @@ module.exports = [ }, }, { - // Browser-side scripts get concatenated at runtime by sync-frontend.js, - // so cross-file references are valid in production. Loosen rules. + // Browser-side classic scripts share one global lexical environment and + // are loaded in a validated order, so cross-file references are valid. files: [ 'tauri-ui/app.js', 'tauri-ui/app-ux.js', @@ -219,6 +229,9 @@ module.exports = [ 'tauri-ui/app-cli.js', 'tauri-ui/app-diagnostics.js', 'tauri-ui/app-audit.js', + 'tauri-ui/app-tool-status.js', + 'tauri-ui/app-environment.js', + 'tauri-ui/app-mcp.js', ], languageOptions: { sourceType: 'script', diff --git a/hpm-cli.js b/hpm-cli.js index 9302eaaf..396d49c8 100644 --- a/hpm-cli.js +++ b/hpm-cli.js @@ -81,7 +81,7 @@ async function main() { return; } - let profiles = []; + let profiles; try { const res = await request('/config'); profiles = res.profiles || []; @@ -136,7 +136,7 @@ async function main() { } } } else if (cmd === 'list') { - let profiles = []; + let profiles; let activeId = ''; try { const res = await request('/config'); diff --git a/lib/ci-governance.js b/lib/ci-governance.js new file mode 100644 index 00000000..a65beb6f --- /dev/null +++ b/lib/ci-governance.js @@ -0,0 +1,61 @@ +'use strict'; + +function actionReferences(workflowText) { + return Array.from(String(workflowText || '').matchAll(/\buses:\s*([^\s#]+)/g), (match) => match[1]); +} + +function unpinnedActionReferences(workflowText) { + return actionReferences(workflowText).filter( + (reference) => + !reference.startsWith('./') && !/^[^@\s]+@[a-f0-9]{40}$/i.test(reference) + ); +} + +function validateCiGovernance(workflowText, dependabotText) { + const errors = unpinnedActionReferences(workflowText).map( + (reference) => `GitHub Action is not pinned to a commit SHA: ${reference}` + ); + for (const required of [ + 'npm ci', + 'npm audit --audit-level=high', + 'npm run test:coverage', + 'npm run release:check', + 'cargo clippy', + 'cargo audit --file src-tauri/Cargo.lock', + ]) { + if (!String(workflowText || '').includes(required)) { + errors.push(`CI workflow is missing required gate: ${required}`); + } + } + for (const ecosystem of ['npm', 'cargo', 'github-actions']) { + if (!String(dependabotText || '').includes(`package-ecosystem: ${ecosystem}`)) { + errors.push(`Dependabot is missing ecosystem: ${ecosystem}`); + } + } + return errors; +} + +function validateReleaseWorkflow(workflowText) { + const errors = []; + for (const required of [ + 'WINDOWS_SIGNING_CERTIFICATE_BASE64 is required', + 'Get-AuthenticodeSignature', + 'npm sbom --sbom-format cyclonedx', + 'cargo-cyclonedx --version 0.5.9', + 'smoke-test-nsis.ps1', + 'actions/attest@', + 'gh release create', + ]) { + if (!String(workflowText || '').includes(required)) { + errors.push(`Release workflow is missing required control: ${required}`); + } + } + return errors; +} + +module.exports = { + actionReferences, + unpinnedActionReferences, + validateCiGovernance, + validateReleaseWorkflow, +}; diff --git a/lib/coverage-metrics.js b/lib/coverage-metrics.js new file mode 100644 index 00000000..0a5ec439 --- /dev/null +++ b/lib/coverage-metrics.js @@ -0,0 +1,52 @@ +'use strict'; + +function percentage(covered, total) { + return total === 0 ? 100 : Math.floor((covered / total) * 10000) / 100; +} + +function collectCoverageMetrics(coverageByFile, includeFile = () => true) { + const totals = { + statements: { covered: 0, total: 0 }, + branches: { covered: 0, total: 0 }, + functions: { covered: 0, total: 0 }, + lines: { covered: 0, total: 0 }, + }; + + for (const [filePath, entry] of Object.entries(coverageByFile || {})) { + if (!includeFile(filePath) || !entry || typeof entry !== 'object') continue; + + for (const count of Object.values(entry.s || {})) { + totals.statements.total += 1; + if (Number(count) > 0) totals.statements.covered += 1; + } + for (const count of Object.values(entry.f || {})) { + totals.functions.total += 1; + if (Number(count) > 0) totals.functions.covered += 1; + } + for (const counts of Object.values(entry.b || {})) { + for (const count of Array.isArray(counts) ? counts : []) { + totals.branches.total += 1; + if (Number(count) > 0) totals.branches.covered += 1; + } + } + + const lineCounts = new Map(); + for (const [statementId, location] of Object.entries(entry.statementMap || {})) { + const line = Number(location?.start?.line); + if (!Number.isInteger(line) || line <= 0) continue; + const count = Number(entry.s?.[statementId] || 0); + lineCounts.set(line, Math.max(lineCounts.get(line) || 0, count)); + } + totals.lines.total += lineCounts.size; + totals.lines.covered += Array.from(lineCounts.values()).filter((count) => count > 0).length; + } + + return Object.fromEntries( + Object.entries(totals).map(([name, value]) => [ + name, + { ...value, percentage: percentage(value.covered, value.total) }, + ]) + ); +} + +module.exports = { collectCoverageMetrics, percentage }; diff --git a/lib/proxy/audit.js b/lib/proxy/audit.js index 6bfa95a9..6e8e09bc 100644 --- a/lib/proxy/audit.js +++ b/lib/proxy/audit.js @@ -1,11 +1,13 @@ const fs = require('fs'); const path = require('path'); const { DATA_DIR } = require('./config'); +const { createLogger } = require('./logger'); const { parseBoundedInteger, readLastLinesOfJsonl: readSharedLastLinesOfJsonl, } = require('./management-utils'); +const safeLog = createLogger('audit'); const AUDIT_LOG_FILE = path.join(DATA_DIR, 'conversations-audit.jsonl'); const AUDIT_LOG_MAX_BYTES = parseBoundedInteger( process.env.PROXY_AUDIT_MAX_BYTES, @@ -79,7 +81,7 @@ async function processAuditQueue() { await rotateAuditFileIfNeeded(AUDIT_LOG_FILE, AUDIT_LOG_MAX_BYTES, Buffer.byteLength(line)); await fs.promises.appendFile(AUDIT_LOG_FILE, line); } catch (err) { - console.warn('!! failed to write conversation audit: ' + err.message); + safeLog.warn('!! failed to write conversation audit: ' + err.message); } } writing = false; @@ -95,7 +97,7 @@ function appendAuditRecord(record) { processAuditQueue(); return true; } catch (err) { - console.warn('!! failed to enqueue conversation audit: ' + err.message); + safeLog.warn('!! failed to enqueue conversation audit: ' + err.message); return false; } } @@ -115,7 +117,7 @@ function getAuditRecords(limit = 50) { const safeLimit = parseBoundedInteger(limit, 50, 1, 500); return readAuditRecordsFromFile(AUDIT_LOG_FILE, safeLimit); } catch (err) { - console.error('!! failed to read conversation audit:', err.message); + safeLog.error('!! failed to read conversation audit:', err.message); return []; } } @@ -138,7 +140,7 @@ function clearAuditRecords() { } return true; } catch (err) { - console.error('!! failed to clear conversation audit:', err.message); + safeLog.error('!! failed to clear conversation audit:', err.message); return false; } } diff --git a/lib/proxy/auth-resolver.js b/lib/proxy/auth-resolver.js index 44bb2d38..81f544c1 100644 --- a/lib/proxy/auth-resolver.js +++ b/lib/proxy/auth-resolver.js @@ -1,7 +1,9 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); +const { createLogger } = require('./logger'); +const safeLog = createLogger('auth-resolver'); function createAuthResolver(deps) { const { loadConfig, @@ -133,14 +135,14 @@ function createAuthResolver(deps) { ? getFreshAntigravityOfficialAccessToken() : refreshGoogleAccessToken(credsPath, content); if (!(content.__readOnly && content.__source === 'antigravity-state')) { - console.log('[auth] Google OAuth token expired or expiring soon, refreshing...'); + safeLog.info('[auth] Google OAuth token expired or expiring soon, refreshing...'); } refreshPromise .then((newToken) => { resolveGoogleOfficialLoginPrepared(prepared, profile, newToken, resolve); }) .catch((err) => { - console.error('[auth] Failed to refresh Google token:', err.message); + safeLog.error('[auth] Failed to refresh Google token:', err.message); if ( !isRedactedSecret(profile.apiKey) && isGoogleOfficialLoginToken(profile.apiKey) @@ -209,7 +211,7 @@ function createAuthResolver(deps) { prepared.authProvider = 'openai'; } } catch (e) { - console.error('[auth] Error loading Codex OAuth credentials:', e.message); + safeLog.error('[auth] Error loading Codex OAuth credentials:', e.message); } } } diff --git a/lib/proxy/cert-management.js b/lib/proxy/cert-management.js index e82db5a1..44098f98 100644 --- a/lib/proxy/cert-management.js +++ b/lib/proxy/cert-management.js @@ -160,7 +160,7 @@ function queryThumbprintInRootStores(thumbprint, execFile, callback) { } function queryCurrentCaTrusted(execFile, callback) { - let thumbprint = ''; + let thumbprint; try { thumbprint = certificateThumbprint(CA_CERT_FILE); } catch (err) { @@ -199,7 +199,7 @@ function waitForThumbprintInRootStores(thumbprint, options, callback) { } function waitForCurrentCaTrusted(options, callback) { - let thumbprint = ''; + let thumbprint; try { thumbprint = certificateThumbprint(CA_CERT_FILE); } catch (err) { diff --git a/lib/proxy/cert.js b/lib/proxy/cert.js index 220ae599..e844fcd3 100644 --- a/lib/proxy/cert.js +++ b/lib/proxy/cert.js @@ -3,7 +3,9 @@ const path = require('path'); const crypto = require('crypto'); const { DATA_DIR } = require('./config'); const { X509Certificate } = require('crypto'); +const { createLogger } = require('./logger'); +const safeLog = createLogger('cert'); const CERT_DIR = process.env.PROXY_CERT_DIR || path.join(DATA_DIR, '.certs'); const CA_CERT_FILE = path.join(CERT_DIR, 'proxy-ca-cert.pem'); const CA_KEY_FILE = path.join(CERT_DIR, 'proxy-ca-key.pem'); @@ -103,8 +105,8 @@ function generateSelfSignedCert() { fs.writeFileSync(KEY_FILE, forge.pki.privateKeyToPem(keys.privateKey)); fs.writeFileSync(CERT_FILE, forge.pki.certificateToPem(cert)); - console.log('[cert] Certificate generated:', CERT_FILE); - console.log('[cert] Trust CA certificate:', CA_CERT_FILE); + safeLog.info('[cert] Certificate generated:', CERT_FILE); + safeLog.info('[cert] Trust CA certificate:', CA_CERT_FILE); } function caCertLooksValid() { @@ -167,12 +169,11 @@ function ensureCert() { if (caCertLooksValid() && serverCertLooksValid()) { return { cert: fs.readFileSync(CERT_FILE), key: fs.readFileSync(KEY_FILE) }; } - console.log('[cert] Generating local CA signed certificate...'); + safeLog.info('[cert] Generating local CA signed certificate...'); try { generateSelfSignedCert(); } catch (e) { - console.error('[cert] Failed:', e.message); - console.error(e); + safeLog.error({ err: e }, '[cert] Certificate generation failed'); process.exit(1); } return { cert: fs.readFileSync(CERT_FILE), key: fs.readFileSync(KEY_FILE) }; diff --git a/lib/proxy/claude-desktop.js b/lib/proxy/claude-desktop.js index 096824e8..3756dbb8 100644 --- a/lib/proxy/claude-desktop.js +++ b/lib/proxy/claude-desktop.js @@ -427,7 +427,7 @@ function listJsonFiles(rootDir, limit = 80) { const stack = [rootDir]; while (stack.length && files.length < limit) { const dir = stack.pop(); - let entries = []; + let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (err) { diff --git a/lib/proxy/claude-self-test-target.js b/lib/proxy/claude-self-test-target.js index f030b18f..557002d7 100644 --- a/lib/proxy/claude-self-test-target.js +++ b/lib/proxy/claude-self-test-target.js @@ -33,8 +33,8 @@ function createClaudeSelfTestTargetResolver(dependencies = {}) { if (!route?.from) return null; const gateway = getClaudeDesktopGatewayConfig(); - let baseUrl = ''; - let token = ''; + let baseUrl; + let token; if (gateway?.isLocalProxy && gateway.baseUrl) { baseUrl = gateway.baseUrl; token = gateway.token || ''; diff --git a/lib/proxy/config.js b/lib/proxy/config.js index 02881f6e..83a8e5bf 100644 --- a/lib/proxy/config.js +++ b/lib/proxy/config.js @@ -3,7 +3,9 @@ const path = require('path'); const secretCrypto = require('./secret-crypto'); const { validateConfig } = require('./validator'); const { mergeBuiltinModelRoutes } = require('./builtin-model-routes'); +const { createLogger } = require('./logger'); +const safeLog = createLogger('config'); const { encryptKey, decryptKey, decryptKeysStrict, SECRET_DECRYPTION_ERROR_CODE } = secretCrypto; const emittedConfigWarnings = new Set(); @@ -59,7 +61,7 @@ function migrateLegacyRuntimeFiles(options = {}) { return result; } - let entries = []; + let entries; try { entries = fs.readdirSync(projectRoot, { withFileTypes: true }); } catch (err) { @@ -326,7 +328,7 @@ function validateLoadedConfig(config, options = {}) { for (const warning of validation.warnings || []) { if (emittedConfigWarnings.has(warning)) continue; emittedConfigWarnings.add(warning); - console.warn('[config] Security warning:', warning); + safeLog.warn('[config] Security warning:', warning); } } return config; @@ -340,7 +342,7 @@ function loadConfig(options = {}) { 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); + safeLog.warn('[config] Failed to read proxy.config.json:', err.message); return DEFAULT_CONFIG; } } diff --git a/lib/proxy/core-request-handler.js b/lib/proxy/core-request-handler.js index c53cc886..14c3b502 100644 --- a/lib/proxy/core-request-handler.js +++ b/lib/proxy/core-request-handler.js @@ -1,6 +1,8 @@ const http = require('http'); const https = require('https'); +const { createLogger } = require('./logger'); +const safeLog = createLogger('core-request-handler'); function createCoreRequestHandler(dependencies = {}) { const { AUDIT_MAX_CAPTURE_BYTES, @@ -9,6 +11,7 @@ function createCoreRequestHandler(dependencies = {}) { appendAuditRecord, applyCliSessionModelOverride, applyManagedAuthHeaders, + authorizeManagementRequest, authorizeEntryRequest, buildOutgoingHeaders, captureRequestBody, @@ -59,7 +62,6 @@ function createCoreRequestHandler(dependencies = {}) { isOpenAIResponsesPath, isPublicLocalManagementPath, isQuietLocalPollRequest, - isTauriManagementOrigin, loadConfig, log, logModelMapping, @@ -207,11 +209,8 @@ function createCoreRequestHandler(dependencies = {}) { 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( + if (!authorizeManagementRequest(clientReq, MANAGEMENT_TOKEN)) { + safeLog.warn( `[mgmt] Unauthorized management request from ${clientIp}: ${method} ${urlPath}` ); clientRes.writeHead(401, { 'Content-Type': 'application/json' }); @@ -660,7 +659,7 @@ function createCoreRequestHandler(dependencies = {}) { }); if (recovery.type === 'failover') { - console.warn( + safeLog.warn( `[Failover] Upstream returned status ${code}, falling back to backup profile: ${recovery.profile.id}` ); const backupPrepared = { ...currentPrepared, profile: recovery.profile }; @@ -670,7 +669,7 @@ function createCoreRequestHandler(dependencies = {}) { } if (recovery.type === 'retry') { - console.warn( + safeLog.warn( `[Retry] Upstream returned ${code}, retrying in ${Math.round(recovery.delayMs)}ms... (Attempt ${attempt}/${MAX_RETRIES})` ); proxyRes.destroy(); @@ -876,7 +875,7 @@ function createCoreRequestHandler(dependencies = {}) { profiles: getProfiles(loadConfig()) || [], }); if (recovery.type === 'failover') { - console.warn( + safeLog.warn( `[Failover] Upstream timed out, falling back to backup profile: ${recovery.profile.id}` ); proxyReq.destroy(); @@ -884,7 +883,7 @@ function createCoreRequestHandler(dependencies = {}) { return; } if (recovery.type === 'retry') { - console.warn( + safeLog.warn( `[Retry] Upstream timeout, retrying in ${Math.round(recovery.delayMs)}ms... (Attempt ${attempt}/${MAX_RETRIES})` ); proxyReq.destroy(); @@ -903,14 +902,14 @@ function createCoreRequestHandler(dependencies = {}) { profiles: getProfiles(loadConfig()) || [], }); if (recovery.type === 'failover') { - console.warn( + safeLog.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( + safeLog.warn( `[Retry] Upstream error (${err.message}), retrying in ${Math.round(recovery.delayMs)}ms... (Attempt ${attempt}/${MAX_RETRIES})` ); setTimeout(() => doForward(currentPrepared, attempt + 1), recovery.delayMs); diff --git a/lib/proxy/diagnostics.js b/lib/proxy/diagnostics.js index bd9060b1..e8f8c04b 100644 --- a/lib/proxy/diagnostics.js +++ b/lib/proxy/diagnostics.js @@ -2,7 +2,9 @@ const fs = require('fs'); const path = require('path'); const { DATA_DIR, DEFAULT_CONFIG } = require('./config'); const { parseBoundedInteger } = require('./management-utils'); +const { createLogger } = require('./logger'); +const safeLog = createLogger('diagnostics'); const GEMINI_SAMPLE_FILE = path.join(DATA_DIR, 'gemini-tool-samples.jsonl'); const TOOL_AUDIT_FILE = path.join(DATA_DIR, 'tool-call-audit.jsonl'); const RESPONSE_SAMPLE_FILE = path.join(DATA_DIR, 'response-samples.jsonl'); @@ -136,7 +138,7 @@ function createDiagnostics(deps) { fileSizes.set(filePath, currentSize + Buffer.byteLength(content)); fileSizes.set(filePath + ':writes', (fileSizes.get(filePath + ':writes') || 0) + 1); } catch (err) { - console.warn( + safeLog.warn( '!! failed to write diagnostics ' + path.basename(filePath) + ': ' + err.message ); } @@ -337,7 +339,7 @@ function createDiagnostics(deps) { meta: meta || {}, }; enqueueWrite(TOOL_AUDIT_FILE, entry, 5 * 1024 * 1024, false); - console.warn( + safeLog.warn( '!! invalid tool_use audit source=' + source + ' tool=' + diff --git a/lib/proxy/env-tools.js b/lib/proxy/env-tools.js index a2b25337..2c334c74 100644 --- a/lib/proxy/env-tools.js +++ b/lib/proxy/env-tools.js @@ -4,7 +4,9 @@ const os = require('os'); const net = require('net'); const https = require('https'); const cp = require('child_process'); +const { createLogger } = require('./logger'); +const safeLog = createLogger('env-tools'); const ENV_CHECK_TOOLS = [ { id: 'claude-code', @@ -1068,7 +1070,7 @@ function clearEnvCaches() { function upgradeAntigravityCli() { if (isUpgradeRunning) { - console.log( + safeLog.info( 'Antigravity CLI upgrade is already running in the background. Skipping duplicate request.' ); return Promise.resolve({ ok: true }); @@ -1125,7 +1127,7 @@ function upgradeAntigravityCli() { function downloadFileViaPowershell(url, dest, proxyPort) { return downloadWithCurl(url, dest, proxyPort).catch((curlErr) => { - console.warn(`curl.exe failed: ${curlErr.message}. Falling back to PowerShell.`); + safeLog.warn(`curl.exe failed: ${curlErr.message}. Falling back to PowerShell.`); return downloadWithPowerShell(url, dest, proxyPort); }); } @@ -1172,14 +1174,14 @@ function upgradeAntigravityCli() { fs.copyFileSync(stagingExe, targetExe); execPath = targetExe; } catch (errCopy) { - console.warn( + safeLog.warn( `Could not copy to target path, executing from staging: ${errCopy.message}` ); } cp.execFile(execPath, ['install'], { windowsHide: true }, (errInst) => { if (errInst) { - console.error(`agy.exe install failed: ${errInst.message}`); + safeLog.error(`agy.exe install failed: ${errInst.message}`); } setTimeout(() => { try { diff --git a/lib/proxy/google-official-runtime.js b/lib/proxy/google-official-runtime.js index b9cb30ee..0d60c270 100644 --- a/lib/proxy/google-official-runtime.js +++ b/lib/proxy/google-official-runtime.js @@ -1,5 +1,7 @@ const { writeJsonFileAtomically } = require('./management-utils'); +const { createLogger } = require('./logger'); +const safeLog = createLogger('google-official-runtime'); function createGoogleOfficialRuntime({ makeRequest, googleOfficialCodeAssistUrl, @@ -65,7 +67,7 @@ function createGoogleOfficialRuntime({ if (credsPath && !content.__readOnly) { writeJsonFileAtomically(credsPath, content); } - console.log('[auth] Successfully refreshed Google OAuth access token'); + safeLog.info('[auth] Successfully refreshed Google OAuth access token'); return parsed.access_token; } @@ -182,7 +184,7 @@ function createGoogleOfficialRuntime({ resolve(prepared); }) .catch((err) => { - console.warn('[auth] Google Code Assist project discovery failed:', err.message); + safeLog.warn('[auth] Google Code Assist project discovery failed:', err.message); resolve(prepared); }); } diff --git a/lib/proxy/google-official.js b/lib/proxy/google-official.js index fdf5c3ee..fae2f4e4 100644 --- a/lib/proxy/google-official.js +++ b/lib/proxy/google-official.js @@ -1,7 +1,9 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); +const { createLogger } = require('./logger'); +const safeLog = createLogger('google-official'); 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'; @@ -362,7 +364,7 @@ function readGoogleOfficialCredentialFile() { try { return { credsPath, content: JSON.parse(fs.readFileSync(credsPath, 'utf8')) }; } catch (err) { - console.error('[auth] Error loading Google OAuth credentials:', err.message); + safeLog.error('[auth] Error loading Google OAuth credentials:', err.message); } } return null; diff --git a/lib/proxy/handlers/management-cli-env.js b/lib/proxy/handlers/management-cli-env.js index 3da6361c..3771dab5 100644 --- a/lib/proxy/handlers/management-cli-env.js +++ b/lib/proxy/handlers/management-cli-env.js @@ -3,7 +3,9 @@ const path = require('path'); const crypto = require('crypto'); const { URL } = require('url'); const cp = require('child_process'); +const { createLogger } = require('../logger'); +const safeLog = createLogger('management-cli-env'); const { checkEnvironmentTools, startToolInstallJob, @@ -188,7 +190,7 @@ function resolveCliWorkDir(value, fallbackDir = process.cwd()) { try { stat = fs.statSync(resolved); } catch (err) { - throw new Error('Working directory does not exist: ' + resolved); + throw new Error('Working directory does not exist: ' + resolved, { cause: err }); } if (!stat.isDirectory()) throw new Error('Working directory is not a directory: ' + resolved); return resolved; @@ -407,12 +409,12 @@ function createManagementCliEnvHandlers({ runToolInstall(foundTool) .then((res) => { if (!res.ok) { - console.error(`[cli-launch] Failed to upgrade ${foundTool.name}:`, res.error); + safeLog.error(`[cli-launch] Failed to upgrade ${foundTool.name}:`, res.error); } resolve(); }) .catch((err) => { - console.error(`[cli-launch] Failed to upgrade ${foundTool.name}:`, err.message); + safeLog.error(`[cli-launch] Failed to upgrade ${foundTool.name}:`, err.message); resolve(); }); }); @@ -493,7 +495,7 @@ function createManagementCliEnvHandlers({ model, }); - console.log( + safeLog.info( `[cli-launch] Launching ${foundTool.name} via ${terminalName} in ${workDir} with args: ${launchArgList.length ? launchArgList.join(' ') : 'none'}` ); cp.execFile( @@ -502,7 +504,7 @@ function createManagementCliEnvHandlers({ { cwd: workDir, env: { ...process.env, ...injectedEnvs }, windowsHide: false }, (err) => { if (err) { - console.error(`[cli-launch] Exec error:`, err.message); + safeLog.error(`[cli-launch] Exec error:`, err.message); } } ); diff --git a/lib/proxy/handlers/management-diagnostics.js b/lib/proxy/handlers/management-diagnostics.js index f8311293..42c4e36e 100644 --- a/lib/proxy/handlers/management-diagnostics.js +++ b/lib/proxy/handlers/management-diagnostics.js @@ -97,7 +97,7 @@ function getFormattedDiagnosticsOutput() { const proxyEvents = readLastLinesOfJsonl(PROXY_EVENTS_FILE, 100); for (const item of proxyEvents) { const time = item.time || new Date().toISOString(); - let text = ''; + let text; if (item.kind === 'request_start') { text = `[Proxy-Request] ${item.method} ${item.path} (clientIp: ${item.clientIp || 'unknown'}, protocol: ${item.entryProtocol || 'unknown'})`; } else if (item.kind === 'request_end') { diff --git a/lib/proxy/handlers/route-handlers.js b/lib/proxy/handlers/route-handlers.js index 075cdc35..ea586693 100644 --- a/lib/proxy/handlers/route-handlers.js +++ b/lib/proxy/handlers/route-handlers.js @@ -14,18 +14,15 @@ 'use strict'; const { - applyGeminiCooldown, buildGeminiProxyRequestOptions, failGeminiStream, finishGeminiClaudeStream, - geminiRetryHeaders, - geminiUpstreamErrorEvent, - logGeminiDirectError, normalizeGeminiStreamBufferMax, processGeminiStreamChunk, sendGeminiLocalCooldown, } = require('./route-gemini-utils'); const { sendConversionError } = require('./route-error-utils'); +const { createRouteResponseHelpers } = require('./route-response-helpers'); const { applyToolLoopGuardToOpenAIChat } = require('../tool-loop-guard'); function createRouteHandlers(ctx) { @@ -101,132 +98,21 @@ function createRouteHandlers(ctx) { const geminiStreamBufferMaxChars = normalizeGeminiStreamBufferMax( protocolOptions.maxSseBufferChars ); - - function proxyErrorContext(clientReq, upstreamPath, startTime, type, logType, extra) { - return { - method: clientReq.method, - path: clientReq.url, - upstreamPath, - startTime, - type, - logType, - logProxyEvent, - logRequestEnd, - extra, - }; - } - - function boundedResponseContext(clientReq, startTime, type) { - return { - method: clientReq.method, - startTime, - tooLargeLogType: type + '_response_too_large', - errorLogType: type + '_response_error', - }; - } - - function handleConvertedJsonResponse(options) { - const { - proxyRes, - clientReq, - clientRes, - upstreamPath, - startTime, - responseBody, - upstreamProvider, - upstreamErrorType, - successType, - successMeta, - conversionLabel, - conversionLogType, - convert, - } = options; - const statusCode = proxyRes.statusCode; - if (statusCode < 200 || statusCode >= 300) { - sendUpstreamError(clientRes, statusCode, proxyRes.headers, responseBody, upstreamProvider); - logRequestEnd(clientReq.method, upstreamPath, startTime, statusCode, { - type: upstreamErrorType, - }); - return; - } - try { - const response = convert(responseBody); - writeJsonResponse(clientRes, 200, response); - const extraSuccessMeta = - typeof successMeta === 'function' ? successMeta(response) : successMeta; - logRequestEnd(clientReq.method, upstreamPath, startTime, 200, { - type: successType, - ...(extraSuccessMeta || {}), - }); - } catch (err) { - sendConversionError({ - clientReq, - clientRes, - upstreamPath, - startTime, - err, - label: conversionLabel, - logType: conversionLogType, - sendJsonError, - logRequestEnd, - }); - } - } - - function handleGeminiUpstreamError(options) { - const { - proxyRes, - clientReq, - clientRes, - prepared, - upstreamPath, - startTime, - responseBody, - targetModel, - requestEndType, - logDirectError = false, - eventExtra, - } = options; - const statusCode = proxyRes.statusCode; - const geminiError = parseGeminiError(statusCode, responseBody); - const retryAfter = geminiError.retryAfterSeconds; - sendUpstreamError( - clientRes, - statusCode, - geminiRetryHeaders(proxyRes.headers, statusCode, retryAfter), - responseBody, - 'gemini' - ); - const cooldownSeconds = applyGeminiCooldown({ - statusCode, - retryAfterSeconds: retryAfter, - profileId: prepared.profile && prepared.profile.id, - targetModel, - setGeminiCooldown, - }); - if (logDirectError) { - logGeminiDirectError({ - clientReq, - upstreamPath, - statusCode, - targetModel, - geminiError, - cooldownSeconds, - log, - }); - } - const extraEventFields = - typeof eventExtra === 'function' ? eventExtra(geminiError) : eventExtra; - logProxyEvent( - 'upstream_error', - geminiUpstreamErrorEvent(clientReq, upstreamPath, statusCode, geminiError, extraEventFields) - ); - logRequestEnd(clientReq.method, upstreamPath, startTime, statusCode, { - type: requestEndType, - errorType: geminiError.type, - retryAfterSeconds: retryAfter, - }); - } + const { + boundedResponseContext, + handleConvertedJsonResponse, + handleGeminiUpstreamError, + proxyErrorContext, + } = createRouteResponseHelpers({ + log, + logProxyEvent, + logRequestEnd, + parseGeminiError, + sendJsonError, + sendUpstreamError, + setGeminiCooldown, + writeJsonResponse, + }); // MCP 拦截(仅被 handleClaudeMessagesToOpenAIChat 使用,放在本文件) async function handleMcpInterception( diff --git a/lib/proxy/handlers/route-response-helpers.js b/lib/proxy/handlers/route-response-helpers.js new file mode 100644 index 00000000..d40feb21 --- /dev/null +++ b/lib/proxy/handlers/route-response-helpers.js @@ -0,0 +1,157 @@ +'use strict'; + +const { + applyGeminiCooldown, + geminiRetryHeaders, + geminiUpstreamErrorEvent, + logGeminiDirectError, +} = require('./route-gemini-utils'); +const { sendConversionError } = require('./route-error-utils'); + +function createRouteResponseHelpers(deps) { + const { + log, + logProxyEvent, + logRequestEnd, + parseGeminiError, + sendJsonError, + sendUpstreamError, + setGeminiCooldown, + writeJsonResponse, + } = deps; + + function proxyErrorContext(clientReq, upstreamPath, startTime, type, logType, extra) { + return { + method: clientReq.method, + path: clientReq.url, + upstreamPath, + startTime, + type, + logType, + logProxyEvent, + logRequestEnd, + extra, + }; + } + + function boundedResponseContext(clientReq, startTime, type) { + return { + method: clientReq.method, + startTime, + tooLargeLogType: `${type}_response_too_large`, + errorLogType: `${type}_response_error`, + }; + } + + function handleConvertedJsonResponse(options) { + const { + proxyRes, + clientReq, + clientRes, + upstreamPath, + startTime, + responseBody, + upstreamProvider, + upstreamErrorType, + successType, + successMeta, + conversionLabel, + conversionLogType, + convert, + } = options; + const statusCode = proxyRes.statusCode; + if (statusCode < 200 || statusCode >= 300) { + sendUpstreamError(clientRes, statusCode, proxyRes.headers, responseBody, upstreamProvider); + logRequestEnd(clientReq.method, upstreamPath, startTime, statusCode, { + type: upstreamErrorType, + }); + return; + } + try { + const response = convert(responseBody); + writeJsonResponse(clientRes, 200, response); + const extraSuccessMeta = + typeof successMeta === 'function' ? successMeta(response) : successMeta; + logRequestEnd(clientReq.method, upstreamPath, startTime, 200, { + type: successType, + ...(extraSuccessMeta || {}), + }); + } catch (err) { + sendConversionError({ + clientReq, + clientRes, + upstreamPath, + startTime, + err, + label: conversionLabel, + logType: conversionLogType, + sendJsonError, + logRequestEnd, + }); + } + } + + function handleGeminiUpstreamError(options) { + const { + proxyRes, + clientReq, + clientRes, + prepared, + upstreamPath, + startTime, + responseBody, + targetModel, + requestEndType, + logDirectError = false, + eventExtra, + } = options; + const statusCode = proxyRes.statusCode; + const geminiError = parseGeminiError(statusCode, responseBody); + const retryAfter = geminiError.retryAfterSeconds; + sendUpstreamError( + clientRes, + statusCode, + geminiRetryHeaders(proxyRes.headers, statusCode, retryAfter), + responseBody, + 'gemini' + ); + const cooldownSeconds = applyGeminiCooldown({ + statusCode, + retryAfterSeconds: retryAfter, + profileId: prepared.profile && prepared.profile.id, + targetModel, + setGeminiCooldown, + }); + if (logDirectError) { + logGeminiDirectError({ + clientReq, + upstreamPath, + statusCode, + targetModel, + geminiError, + cooldownSeconds, + log, + }); + } + const extraEventFields = + typeof eventExtra === 'function' ? eventExtra(geminiError) : eventExtra; + logProxyEvent( + 'upstream_error', + geminiUpstreamErrorEvent(clientReq, upstreamPath, statusCode, geminiError, extraEventFields) + ); + logRequestEnd(clientReq.method, upstreamPath, startTime, statusCode, { + type: requestEndType, + errorType: geminiError.type, + retryAfterSeconds: retryAfter, + }); + } + + return { + boundedResponseContext, + handleConvertedJsonResponse, + handleGeminiUpstreamError, + proxyErrorContext, + }; +} + +module.exports = { createRouteResponseHelpers }; diff --git a/lib/proxy/local-handlers.js b/lib/proxy/local-handlers.js index d4da4720..30ffc548 100644 --- a/lib/proxy/local-handlers.js +++ b/lib/proxy/local-handlers.js @@ -1,6 +1,10 @@ +const { createLogger } = require('./logger'); + +const safeLog = createLogger('local-handlers'); + function logFilteredToolBody(kind, original, filtered) { if (original === filtered) return; - console.warn('!! filtered invalid tool_use in ' + kind + ' response'); + safeLog.warn('!! filtered invalid tool_use in ' + kind + ' response'); } function sendGeminiRateLimit( @@ -38,7 +42,7 @@ function handleCountTokens( logRequestEnd, startTime ) { - let inputTokens = 0; + let inputTokens; try { const parsed = JSON.parse(requestBuffer.toString('utf8')); if (clientRes.__auditData && !clientRes.__auditData.originalModel && parsed && parsed.model) { @@ -60,7 +64,7 @@ function handleCountTokens( 'access-control-allow-headers': '*', }); clientRes.end(JSON.stringify({ input_tokens: inputTokens })); - console.log( + safeLog.info( 'ok ' + clientReq.method + ' ' + diff --git a/lib/proxy/management-auth.js b/lib/proxy/management-auth.js new file mode 100644 index 00000000..34d153b1 --- /dev/null +++ b/lib/proxy/management-auth.js @@ -0,0 +1,41 @@ +'use strict'; + +const crypto = require('crypto'); + +const MAX_MANAGEMENT_TOKEN_BYTES = 4096; + +function readManagementTokenHeader(req) { + const value = req && req.headers ? req.headers['x-mgmt-token'] : ''; + if (Array.isArray(value) || value == null) return ''; + const token = String(value).trim(); + if (!token || Buffer.byteLength(token, 'utf8') > MAX_MANAGEMENT_TOKEN_BYTES) return ''; + return token; +} + +function managementTokenMatches(expectedToken, providedToken) { + const expected = String(expectedToken || ''); + const provided = String(providedToken || ''); + if (!expected || !provided) return false; + + const expectedBuffer = Buffer.from(expected, 'utf8'); + const providedBuffer = Buffer.from(provided, 'utf8'); + if ( + expectedBuffer.length === 0 || + expectedBuffer.length > MAX_MANAGEMENT_TOKEN_BYTES || + expectedBuffer.length !== providedBuffer.length + ) { + return false; + } + return crypto.timingSafeEqual(expectedBuffer, providedBuffer); +} + +function authorizeManagementRequest(req, expectedToken) { + return managementTokenMatches(expectedToken, readManagementTokenHeader(req)); +} + +module.exports = { + MAX_MANAGEMENT_TOKEN_BYTES, + authorizeManagementRequest, + managementTokenMatches, + readManagementTokenHeader, +}; diff --git a/lib/proxy/management-utils.js b/lib/proxy/management-utils.js index 6bbd9e1f..b2fe1cdc 100644 --- a/lib/proxy/management-utils.js +++ b/lib/proxy/management-utils.js @@ -1,8 +1,11 @@ const fs = require('fs'); const path = require('path'); +const { createLogger } = require('./logger'); +const safeLog = createLogger('management-utils'); const DEFAULT_JSON_BODY_LIMIT = 1024 * 1024; const MAX_JSON_BODY_LIMIT = 4 * 1024 * 1024; +const PRIVATE_FILE_MODE = 0o600; const LOCAL_POLL_ENDPOINTS = new Set([ '/v1/local/health', @@ -161,7 +164,7 @@ function readLastLinesOfJsonl(filePath, maxLines = 50, maxBytes = 128 * 1024) { } return result; } catch (err) { - console.warn( + safeLog.warn( `[diagnostics] Failed to read last lines of ${path.basename(filePath)}: ${err.message}` ); return []; @@ -184,8 +187,9 @@ function writeTextFileAtomically(filePath, content, encoding = 'utf8') { `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` ); try { - fs.writeFileSync(tmpPath, content, encoding); + fs.writeFileSync(tmpPath, content, { encoding, mode: PRIVATE_FILE_MODE }); fs.renameSync(tmpPath, filePath); + if (process.platform !== 'win32') fs.chmodSync(filePath, PRIVATE_FILE_MODE); } catch (err) { try { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); @@ -285,6 +289,7 @@ function installManagementCorsGuard(req, res) { } module.exports = { + PRIVATE_FILE_MODE, fileSignature, parseBoundedInteger, parsePortOrDefault, diff --git a/lib/proxy/mcp-hub.js b/lib/proxy/mcp-hub.js index 9dff885f..fdd5d237 100644 --- a/lib/proxy/mcp-hub.js +++ b/lib/proxy/mcp-hub.js @@ -3,8 +3,11 @@ const https = require('https'); const fs = require('fs'); const path = require('path'); const { DATA_DIR } = require('./config'); -const { parseBoundedInteger } = require('./management-utils'); +const { createLogger } = require('./logger'); +const { parseBoundedInteger, PRIVATE_FILE_MODE } = require('./management-utils'); +const { safeRoutingPatternMatches } = require('./safe-routing-pattern'); +const safeLog = createLogger('mcp-hub'); const MCP_LOG_FILE = path.join(DATA_DIR, 'mcp-execution.jsonl'); const MCP_LOG_MAX_BYTES = parseBoundedInteger( process.env.MCP_LOG_MAX_BYTES, @@ -12,15 +15,6 @@ const MCP_LOG_MAX_BYTES = parseBoundedInteger( 256 * 1024, 64 * 1024 * 1024 ); -const MCP_LOG_MAX_TEXT_CHARS = parseBoundedInteger( - process.env.MCP_LOG_MAX_TEXT_CHARS, - 12000, - 256, - 1024 * 1024 -); -const MCP_LOG_MAX_DEPTH = 6; -const MCP_LOG_MAX_ARRAY_ITEMS = 40; -const MCP_LOG_MAX_OBJECT_KEYS = 80; const MCP_RPC_MAX_RESPONSE_BYTES = parseBoundedInteger( process.env.MCP_RPC_MAX_RESPONSE_BYTES, 1024 * 1024, @@ -31,40 +25,43 @@ const MCP_RPC_MAX_RESPONSE_BYTES = parseBoundedInteger( const mcpLogQueue = []; let mcpLogWriting = false; -function shouldRedactMcpKey(key) { - return /(?:api[_-]?key|authorization|token|secret|password|cookie|credential|refresh[_-]?token|access[_-]?token)/i.test( - String(key || '') - ); +function jsonByteLength(value) { + if (value === undefined) return 0; + try { + return Buffer.byteLength(JSON.stringify(value)); + } catch (error) { + return null; + } } -function compactMcpLogValue(value, depth = 0, key = '') { - if (shouldRedactMcpKey(key)) return value ? '[redacted]' : value; - if (value == null) return value; - if (typeof value === 'string') { - if (value.length <= MCP_LOG_MAX_TEXT_CHARS) return value; - return value.slice(0, MCP_LOG_MAX_TEXT_CHARS) + '...[truncated]'; - } - if (typeof value !== 'object') return value; - if (depth >= MCP_LOG_MAX_DEPTH) return '[max-depth]'; - if (Array.isArray(value)) { - const result = value - .slice(0, MCP_LOG_MAX_ARRAY_ITEMS) - .map((item) => compactMcpLogValue(item, depth + 1, key)); - if (value.length > MCP_LOG_MAX_ARRAY_ITEMS) { - result.push(`[${value.length - MCP_LOG_MAX_ARRAY_ITEMS} more items]`); - } - return result; - } +function boundedMetadataString(value, maxChars = 128) { + return String(value == null ? '' : value).trim().slice(0, maxChars); +} - const result = {}; - const entries = Object.entries(value); - for (const [childKey, childValue] of entries.slice(0, MCP_LOG_MAX_OBJECT_KEYS)) { - result[childKey] = compactMcpLogValue(childValue, depth + 1, childKey); +function buildMcpExecutionRecord(data, now = new Date()) { + const source = data && typeof data === 'object' && !Array.isArray(data) ? data : {}; + const record = { + time: now.toISOString(), + serverId: boundedMetadataString(source.serverId), + toolName: boundedMetadataString(source.toolName), + status: source.status === 'success' ? 'success' : 'error', + elapsedMs: parseBoundedInteger(source.elapsed, 0, 0, 24 * 60 * 60 * 1000), + }; + if (Object.prototype.hasOwnProperty.call(source, 'input')) { + record.inputBytes = jsonByteLength(source.input); } - if (entries.length > MCP_LOG_MAX_OBJECT_KEYS) { - result.__truncatedKeys = entries.length - MCP_LOG_MAX_OBJECT_KEYS; + if (Object.prototype.hasOwnProperty.call(source, 'result')) { + record.resultBytes = jsonByteLength(source.result); + } + if (source.error) { + record.errorType = boundedMetadataString( + source.error && typeof source.error === 'object' + ? source.error.name || source.error.code || 'Error' + : 'Error', + 64 + ); } - return result; + return record; } async function rotateMcpLogIfNeeded() { @@ -79,12 +76,14 @@ async function rotateMcpLogIfNeeded() { const rotated = MCP_LOG_FILE + '.1'; try { await fs.promises.unlink(rotated); - } catch (err) {} + } catch (err) { + if (!err || err.code !== 'ENOENT') throw err; + } try { await fs.promises.rename(MCP_LOG_FILE, rotated); } catch (err) { await fs.promises.copyFile(MCP_LOG_FILE, rotated); - await fs.promises.writeFile(MCP_LOG_FILE, ''); + await fs.promises.writeFile(MCP_LOG_FILE, '', { mode: PRIVATE_FILE_MODE }); } } @@ -95,21 +94,28 @@ async function processMcpLogQueue() { const entry = mcpLogQueue.shift(); try { await rotateMcpLogIfNeeded(); - await fs.promises.appendFile(MCP_LOG_FILE, JSON.stringify(entry) + '\n'); - } catch (err) {} + await fs.promises.appendFile(MCP_LOG_FILE, JSON.stringify(entry) + '\n', { + encoding: 'utf8', + mode: PRIVATE_FILE_MODE, + }); + if (process.platform !== 'win32') { + await fs.promises.chmod(MCP_LOG_FILE, PRIVATE_FILE_MODE); + } + } catch (err) { + safeLog.warn({ err }, 'failed to persist MCP execution metadata'); + } } mcpLogWriting = false; } function logMcpExecution(data) { - const record = { - time: new Date().toISOString(), - ...compactMcpLogValue(data || {}), - }; + const record = buildMcpExecutionRecord(data); try { mcpLogQueue.push(record); processMcpLogQueue(); - } catch (err) {} + } catch (err) { + safeLog.warn({ err }, 'failed to enqueue MCP execution metadata'); + } } async function flushMcpLogWrites() { @@ -245,6 +251,7 @@ function mergeClaudeTools(existingTools, injectedTools) { function createMcpHub(deps) { const { getConfig } = deps; + const logger = deps.logger || safeLog; const listCache = new Map(); function getMcpConfig() { @@ -306,11 +313,7 @@ function createMcpHub(deps) { if (!item) return false; if (item.toolName === toolName) return true; if (!item.pattern) return false; - try { - return new RegExp(item.pattern).test(toolName); - } catch (err) { - return false; - } + return safeRoutingPatternMatches(item.pattern, toolName); }); return route ? config.servers.find((server) => server.id === route.serverId) || null : null; } @@ -335,8 +338,9 @@ function createMcpHub(deps) { if (!response.ok) { if (attempt <= maxRetries) { - console.warn( - `[MCP Hub] Retrying tool call ${toolName} (attempt ${attempt + 1}/${maxRetries + 1})...` + logger.warn( + { toolName, attempt: attempt + 1, maxAttempts: maxRetries + 1 }, + 'retrying MCP tool call' ); const delay = parseBoundedInteger(server && server.retryDelayMs, 500, 0, 60000) * attempt; await new Promise((resolve) => setTimeout(resolve, delay)); @@ -391,7 +395,7 @@ function createMcpHub(deps) { serverId: server.id, toolName: tc.name, input: tc.input, - error: e.message, + error: e, elapsed, status: 'error', }); @@ -417,7 +421,7 @@ module.exports = { mergeClaudeTools, upstreamMcpToolName, _test: { - compactMcpLogValue, + buildMcpExecutionRecord, flushMcpLogWrites, logMcpExecution, mcpRequestHeaders, diff --git a/lib/proxy/metrics.js b/lib/proxy/metrics.js index ff96c61d..4aab1b1f 100644 --- a/lib/proxy/metrics.js +++ b/lib/proxy/metrics.js @@ -153,9 +153,8 @@ function createMetrics(_options) { const buckets = entry.instance.buckets; for (const [k, e] of entry.instance.values) { const labelObj = e.labelObj || {}; - let cumulative = 0; for (let i = 0; i < buckets.length; i += 1) { - cumulative = e.counts[i]; + const cumulative = e.counts[i]; const labels = Object.assign({}, labelObj, { le: String(buckets[i]) }); out.push(entry.name + '_bucket' + formatLabels(labels) + ' ' + cumulative + '\n'); } diff --git a/lib/proxy/models.js b/lib/proxy/models.js index 1d330ee5..ad031860 100644 --- a/lib/proxy/models.js +++ b/lib/proxy/models.js @@ -1,3 +1,7 @@ +const { createLogger } = require('./logger'); + +const safeLog = createLogger('models'); + function localModelIds(modelRoutes) { return Array.from( new Set( @@ -180,7 +184,7 @@ function handleModelsRequest( }); if (clientReq.method === 'HEAD') clientRes.end(); else clientRes.end(text); - console.log( + safeLog.info( 'ok ' + clientReq.method + ' ' + diff --git a/lib/proxy/protocol-openai.js b/lib/proxy/protocol-openai.js index 2fb411b7..ab651e3a 100644 --- a/lib/proxy/protocol-openai.js +++ b/lib/proxy/protocol-openai.js @@ -1,3 +1,5 @@ +const { createSseBufferAppender, parseBoundedInteger } = require('./sse-buffer'); + function createOpenAIProtocol(deps) { const { cleanToolArgs, @@ -14,28 +16,14 @@ function createOpenAIProtocol(deps) { } = deps; const DEFAULT_SSE_BUFFER_MAX_CHARS = 1024 * 1024; const MAX_SSE_BUFFER_MAX_CHARS = 4 * 1024 * 1024; - const maxSseBufferChars = boundedInteger( + const maxSseBufferChars = parseBoundedInteger( protocolOptions && protocolOptions.maxSseBufferChars, DEFAULT_SSE_BUFFER_MAX_CHARS, 1, MAX_SSE_BUFFER_MAX_CHARS ); - function boundedInteger(value, defaultValue, min, max) { - const parsed = - typeof value === 'number' ? value : Number.parseInt(String(value == null ? '' : value), 10); - const fallback = Number.isFinite(defaultValue) ? Math.trunc(defaultValue) : min; - const integer = Number.isFinite(parsed) ? Math.trunc(parsed) : fallback; - return Math.min(max, Math.max(min, integer)); - } - - function appendSseBuffer(state, chunkText, label) { - state.buffer = (state.buffer || '') + chunkText; - if (state.buffer.length > maxSseBufferChars) { - state.buffer = ''; - throw new Error(label + ' SSE event too large; max ' + maxSseBufferChars + ' chars'); - } - } + const appendSseBuffer = createSseBufferAppender(maxSseBufferChars); function openAIContentToClaudeContent(content) { if (content == null) return ''; diff --git a/lib/proxy/proxy-transport.js b/lib/proxy/proxy-transport.js index b0838053..2a92652c 100644 --- a/lib/proxy/proxy-transport.js +++ b/lib/proxy/proxy-transport.js @@ -23,7 +23,7 @@ function parseHttpUrl(rawUrl, label) { try { parsed = new URL(String(rawUrl)); } catch (error) { - throw new TypeError(`${label} must be a valid URL`); + throw new TypeError(`${label} must be a valid URL`, { cause: error }); } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new TypeError(`${label} must use http: or https:`); diff --git a/lib/proxy/rate-limits.js b/lib/proxy/rate-limits.js index 04b08117..25facc52 100644 --- a/lib/proxy/rate-limits.js +++ b/lib/proxy/rate-limits.js @@ -62,7 +62,7 @@ function formatRateLimits(data, nowMs = Date.now()) { max > 0 ? Math.max(0, Math.min(100, Math.round((remaining / max) * 100))) : 0; const windowSec = parseBoundedInteger(limit.window_size_seconds, 0, 0, MAX_RESET_SECONDS); - let windowStr = ''; + let windowStr; if (windowSec >= 604800 && windowSec % 604800 === 0) { windowStr = Math.round(windowSec / 604800) + '周'; } else if (windowSec >= 86400) { @@ -72,7 +72,7 @@ function formatRateLimits(data, nowMs = Date.now()) { } const resetSec = parseBoundedInteger(limit.reset_in_seconds, 0, 0, MAX_RESET_SECONDS); - let resetStr = ''; + let resetStr; if (resetSec < 3600) { resetStr = Math.round(resetSec / 60) + 'm'; } else if (resetSec < 86400) { @@ -128,7 +128,7 @@ function listRecentCodexSessionFiles(maxFiles = 30) { const stack = [root]; while (stack.length) { const dir = stack.pop(); - let entries = []; + let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (err) { diff --git a/lib/proxy/routing.js b/lib/proxy/routing.js index cdd1fbba..d3756b33 100644 --- a/lib/proxy/routing.js +++ b/lib/proxy/routing.js @@ -1,7 +1,9 @@ const { DEFAULT_CONFIG } = require('./config'); const { createRouteIndex } = require('./route-index'); const { parsePortOrDefault } = require('./management-utils'); +const { createLogger } = require('./logger'); +const safeLog = createLogger('routing'); const TARGET_URL_CACHE_LIMIT = 256; const WILDCARD_ROUTE_REGEX_CACHE_LIMIT = 1024; const targetUrlCache = new Map(); @@ -278,7 +280,7 @@ function logModelMapping(kind, method, upstreamPath, prepared, logProxyEvent) { ? 'default' : requestedModel; const targetModel = prepared.rewriteTo !== '-' ? prepared.rewriteTo : requestedModel; - console.log( + safeLog.info( 'route mapping [' + kind + '] ' + diff --git a/lib/proxy/runtime-support.js b/lib/proxy/runtime-support.js index 4209b2f6..b12b6ecc 100644 --- a/lib/proxy/runtime-support.js +++ b/lib/proxy/runtime-support.js @@ -5,15 +5,6 @@ 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]'; @@ -45,8 +36,7 @@ function createRuntimeSupport({ dataDir, initialToken = '', logger } = {}) { function writeManagementTokenFile(managementPort) { try { - managementToken = - readExistingManagementToken(managementPort) || crypto.randomBytes(16).toString('hex'); + managementToken = managementToken || crypto.randomBytes(16).toString('hex'); fs.mkdirSync(dataDir, { recursive: true }); const tokenFiles = [path.join(dataDir, '.mgmt-token')]; const portNumber = Number(managementPort); @@ -125,6 +115,5 @@ function createRuntimeSupport({ dataDir, initialToken = '', logger } = {}) { module.exports = { createRuntimeSupport, - isTauriManagementOrigin, maskManagementToken, }; diff --git a/lib/proxy/safe-routing-pattern.js b/lib/proxy/safe-routing-pattern.js new file mode 100644 index 00000000..309ecd15 --- /dev/null +++ b/lib/proxy/safe-routing-pattern.js @@ -0,0 +1,44 @@ +const safeRegex = require('safe-regex2'); + +const MAX_ROUTING_PATTERN_CHARS = 128; +const MAX_ROUTING_INPUT_CHARS = 128; + +function hasQuantifiedAlternation(pattern) { + return /\((?:\\.|[^()])*\|(?:\\.|[^()])*\)(?:[+*]|\{\d+(?:,\d*)?\})/.test(pattern); +} + +function compileSafeRoutingPattern(value) { + if (typeof value !== 'string' || value.length === 0) { + return { ok: false, error: 'pattern must be a non-empty string' }; + } + if (value.length > MAX_ROUTING_PATTERN_CHARS) { + return { + ok: false, + error: `pattern must contain at most ${MAX_ROUTING_PATTERN_CHARS} characters`, + }; + } + + let regex; + try { + regex = new RegExp(value); + } catch (error) { + return { ok: false, error: 'pattern is invalid' }; + } + if (!safeRegex(regex) || hasQuantifiedAlternation(value)) { + return { ok: false, error: 'pattern has unsafe backtracking complexity' }; + } + return { ok: true, regex }; +} + +function safeRoutingPatternMatches(pattern, input) { + if (typeof input !== 'string' || input.length > MAX_ROUTING_INPUT_CHARS) return false; + const compiled = compileSafeRoutingPattern(pattern); + return compiled.ok ? compiled.regex.test(input) : false; +} + +module.exports = { + MAX_ROUTING_PATTERN_CHARS, + MAX_ROUTING_INPUT_CHARS, + compileSafeRoutingPattern, + safeRoutingPatternMatches, +}; diff --git a/lib/proxy/secret-crypto.js b/lib/proxy/secret-crypto.js index f3b21a02..d9935bec 100644 --- a/lib/proxy/secret-crypto.js +++ b/lib/proxy/secret-crypto.js @@ -17,6 +17,9 @@ const crypto = require('crypto'); const os = require('os'); const { execFileSync } = require('child_process'); +const { createLogger } = require('./logger'); + +const safeLog = createLogger('secret-crypto'); const DPAPI_PREFIX = 'hpm-dpapi:'; const AES_PREFIX = 'hpm-enc:'; @@ -59,7 +62,7 @@ function decryptAes(cipherText) { decrypted += decipher.final('utf8'); return decrypted; } catch (err) { - console.warn('[crypto] Failed to decrypt AES key:', err.message); + safeLog.warn('[crypto] Failed to decrypt AES key:', err.message); return cipherText; } } @@ -75,7 +78,7 @@ function encryptAes(text) { const tag = cipher.getAuthTag().toString('hex'); return AES_PREFIX + iv.toString('hex') + ':' + encrypted + ':' + tag; } catch (err) { - console.error('[crypto] AES encryption failed:', err.message); + safeLog.error('[crypto] AES encryption failed:', err.message); return text; } } @@ -177,7 +180,7 @@ function dpapiUnprotect(payload) { try { return decryptKeysStrict([payload])[0]; } catch (err) { - console.warn('[crypto] DPAPI unprotect failed:', err.code || 'unprotect_failed'); + safeLog.warn('[crypto] DPAPI unprotect failed:', err.code || 'unprotect_failed'); return payload; } } diff --git a/lib/proxy/server-lifecycle.js b/lib/proxy/server-lifecycle.js index 46525b5e..f9deec07 100644 --- a/lib/proxy/server-lifecycle.js +++ b/lib/proxy/server-lifecycle.js @@ -12,7 +12,6 @@ function startProxyServers(dependencies = {}) { handleRequest, installManagementCorsGuard, log, - maskManagementToken, parsePortOrDefault, writeManagementTokenFile, getRuntimeState, @@ -22,7 +21,6 @@ function startProxyServers(dependencies = {}) { } 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, @@ -194,7 +192,7 @@ function startProxyServers(dependencies = {}) { // 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); + 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]'); @@ -222,8 +220,6 @@ function startProxyServers(dependencies = {}) { } 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(''); diff --git a/lib/proxy/sse-buffer.js b/lib/proxy/sse-buffer.js new file mode 100644 index 00000000..efd43983 --- /dev/null +++ b/lib/proxy/sse-buffer.js @@ -0,0 +1,24 @@ +'use strict'; + +function parseBoundedInteger(value, defaultValue, min, max) { + const parsed = + typeof value === 'number' ? value : Number.parseInt(String(value == null ? '' : value), 10); + const fallback = Number.isFinite(defaultValue) ? Math.trunc(defaultValue) : min; + const integer = Number.isFinite(parsed) ? Math.trunc(parsed) : fallback; + return Math.min(max, Math.max(min, integer)); +} + +function createSseBufferAppender(maxChars) { + if (!Number.isSafeInteger(maxChars) || maxChars <= 0) { + throw new TypeError('maxChars must be a positive safe integer'); + } + return function appendSseBuffer(state, chunkText, label) { + state.buffer = (state.buffer || '') + chunkText; + if (state.buffer.length > maxChars) { + state.buffer = ''; + throw new Error(`${label} SSE event too large; max ${maxChars} chars`); + } + }; +} + +module.exports = { createSseBufferAppender, parseBoundedInteger }; diff --git a/lib/proxy/stream-claude.js b/lib/proxy/stream-claude.js index cbcd6f42..a0fd2123 100644 --- a/lib/proxy/stream-claude.js +++ b/lib/proxy/stream-claude.js @@ -1,3 +1,7 @@ +const { createLogger } = require('./logger'); + +const safeLog = createLogger('stream-claude'); + function createClaudeStream(deps) { const { cleanToolArgs, @@ -385,7 +389,7 @@ function createClaudeStream(deps) { state.pendingToolCall = null; const sanitized = sanitizeGeminiToolCall({ name: pending.name, args: pending.args }, tools); if (sanitized.error) { - console.warn('!! gemini invalid tool call converted to retry text: ' + sanitized.error); + safeLog.warn('!! gemini invalid tool call converted to retry text: ' + sanitized.error); sampleGeminiToolEvent(sanitized.error, pending); writeClaudeTextBlock(clientRes, state, sanitized.text); return; @@ -467,7 +471,7 @@ function createClaudeStream(deps) { try { streamGeminiChunkToClaude(clientRes, JSON.parse(data), state, targetModel, state.tools); } catch (err) { - console.warn( + safeLog.warn( '!! gemini stream parse skipped: ' + err.message + ' data=' + data.slice(0, 500) ); } diff --git a/lib/proxy/tool-configs.js b/lib/proxy/tool-configs.js index 0cf3e4ba..5e51691a 100644 --- a/lib/proxy/tool-configs.js +++ b/lib/proxy/tool-configs.js @@ -702,7 +702,7 @@ function switchCodexMode(options = {}) { connectionMode === 'proxy' ? applyCodexProxy({ modelRoutes: options.modelRoutes }) : restoreCodexProxy(); - let authSwitch = null; + let authSwitch; let authWarning = ''; try { authSwitch = applyAuthSwitch(); diff --git a/lib/proxy/trae-bridge-progress.js b/lib/proxy/trae-bridge-progress.js index 8c29d7ef..ecde21a3 100644 --- a/lib/proxy/trae-bridge-progress.js +++ b/lib/proxy/trae-bridge-progress.js @@ -537,7 +537,7 @@ function createTraeBridgeProgress({ if (stat.size <= offset) continue; const length = Math.min(stat.size - offset, 512 * 1024); const readOffset = stat.size - offset > length ? stat.size - length : offset; - let text = ''; + let text; try { const fd = fs.openSync(item.file, 'r'); const buffer = Buffer.alloc(length); diff --git a/lib/proxy/trae-bridge-utils.js b/lib/proxy/trae-bridge-utils.js index c1fb95c8..04ff6918 100644 --- a/lib/proxy/trae-bridge-utils.js +++ b/lib/proxy/trae-bridge-utils.js @@ -123,7 +123,7 @@ function createTraeBridgeUtils({ if (line.indexOf('data:') === 0) dataLines.push(line.slice(5).trimStart()); } if (!dataLines.length) continue; - let payload = {}; + let payload; try { payload = JSON.parse(dataLines.join('\n')); } catch (err) { @@ -220,7 +220,7 @@ function createTraeBridgeUtils({ try { parsedUrl = new URL(bridgeUrl); } catch (err) { - throw new Error('Invalid Trae bridge stream URL: ' + bridgeUrl); + throw new Error('Invalid Trae bridge stream URL', { cause: err }); } const lib = parsedUrl.protocol === 'https:' ? https : http; const finalState = { text: '', textTruncated: false, rawDone: null, bridgeUrl }; diff --git a/lib/proxy/trae-debug-center.js b/lib/proxy/trae-debug-center.js index 9dd0aa95..a22bf717 100644 --- a/lib/proxy/trae-debug-center.js +++ b/lib/proxy/trae-debug-center.js @@ -225,7 +225,7 @@ function walkLogFiles(root, limit = 24) { const out = []; function walk(dir) { if (out.length >= limit) return; - let entries = []; + let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { diff --git a/lib/proxy/usage.js b/lib/proxy/usage.js index f3ab9e12..7fa085f0 100644 --- a/lib/proxy/usage.js +++ b/lib/proxy/usage.js @@ -2,7 +2,9 @@ const fs = require('fs'); const path = require('path'); const { DATA_DIR } = require('./config'); const { parseBoundedInteger } = require('./management-utils'); +const { createLogger } = require('./logger'); +const safeLog = createLogger('usage'); const USAGE_LOG_FILE = path.join(DATA_DIR, 'usage-stats.jsonl'); const USAGE_LOG_MAX_BYTES = parseBoundedInteger( process.env.PROXY_USAGE_MAX_BYTES, @@ -108,7 +110,7 @@ async function processUsageQueue() { await fs.promises.appendFile(USAGE_LOG_FILE, line); usageSummaryCache.signature = ''; } catch (err) { - console.warn('Failed to log usage:', err.message); + safeLog.warn('Failed to log usage:', err.message); } } usageWriting = false; @@ -131,7 +133,7 @@ function logUsage(data = {}) { processUsageQueue(); return true; } catch (err) { - console.warn('Failed to enqueue usage:', err.message); + safeLog.warn('Failed to enqueue usage:', err.message); return false; } } diff --git a/lib/proxy/validator.js b/lib/proxy/validator.js index c97e3b74..73065211 100644 --- a/lib/proxy/validator.js +++ b/lib/proxy/validator.js @@ -1,19 +1,24 @@ +const { + MAX_ROUTING_PATTERN_CHARS, + compileSafeRoutingPattern, +} = require('./safe-routing-pattern'); + function validateConfig(config) { const errors = []; const warnings = []; - if (!config || typeof config !== 'object') { + if (!config || typeof config !== 'object' || Array.isArray(config)) { return { valid: false, errors: ['config must be an object'], warnings }; } if (!config.activeProfile) errors.push('Missing activeProfile'); + const profileIds = new Set(); 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`); @@ -21,8 +26,8 @@ function validateConfig(config) { } 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.id && profileIds.has(p.id)) errors.push(`Profile [${p.id}] is duplicated`); + if (p.id) profileIds.add(p.id); if (!p.protocol) errors.push(`Profile [${p.id || i}] is missing protocol`); else if (!['openai', 'gemini', 'claude', 'trae'].includes(String(p.protocol))) { errors.push(`Profile [${p.id || i}] has unsupported protocol`); @@ -56,18 +61,27 @@ function validateConfig(config) { 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)) { + if (config.activeProfile && !profileIds.has(config.activeProfile)) { errors.push(`activeProfile [${config.activeProfile}] does not match any profile`); } } validateServerConfig(config.localServer, 'localServer', errors); - if (Array.isArray(config.entryServers)) { + if (config.entryServers != null && !Array.isArray(config.entryServers)) { + errors.push('entryServers must be an array'); + } else if (Array.isArray(config.entryServers)) { + if (config.entryServers.length > 32) { + errors.push('entryServers must contain at most 32 servers'); + } config.entryServers.forEach((entry, i) => validateServerConfig(entry, `entryServers[${i}]`, errors) ); } + validateModelRoutes(config.modelRoutes, profileIds, errors); + validateClaudeDesktopConfig(config.claudeDesktop, profileIds, errors); + validateAppSettings(config.appSettings, errors); + validateProtocolOptions(config.protocolOptions, errors); validateMcpHubConfig(config.mcpHub, errors); validateEntryAuthConfig(config.entryAuth, errors); const exposedServer = [ @@ -121,8 +135,224 @@ function validateOptionalNumber( } } +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function validateOptionalBoolean(value, label, errors) { + if (value != null && typeof value !== 'boolean') { + errors.push(`${label} must be a boolean`); + } +} + +function validateBoundedString(value, label, errors, maxLength, { required = false } = {}) { + if (value == null || value === '') { + if (required) errors.push(`${label} is required`); + return; + } + if (typeof value !== 'string') { + errors.push(`${label} must be a string`); + return; + } + if (value.length > maxLength) errors.push(`${label} is too long`); + if (/[\r\n]/.test(value)) errors.push(`${label} must not contain line breaks`); +} + +function validateHttpUrl(value, label, errors) { + if (value == null || value === '') return; + if (typeof value !== 'string' || value.length > 2048 || !isHttpUrl(value)) { + errors.push(`${label} must be a valid http(s) URL`); + return; + } + try { + const parsed = new URL(value); + if (parsed.username || parsed.password) errors.push(`${label} must not include credentials`); + } catch (error) {} +} + +function validateModelRoutes(modelRoutes, profileIds, errors = [], label = 'modelRoutes') { + if (modelRoutes == null) return { valid: errors.length === 0, errors }; + if (!Array.isArray(modelRoutes)) { + errors.push(`${label} must be an array`); + return { valid: errors.length === 0, errors }; + } + if (modelRoutes.length > 1024) errors.push(`${label} must contain at most 1024 routes`); + + const allowedClientProtocols = new Set(['claude', 'openai-chat', 'openai-responses']); + modelRoutes.forEach((route, index) => { + const routeLabel = `${label}[${index}]`; + if (!isPlainObject(route)) { + errors.push(`${routeLabel} must be an object`); + return; + } + validateBoundedString(route.from, `${routeLabel}.from`, errors, 256, { required: true }); + validateBoundedString(route.to, `${routeLabel}.to`, errors, 256); + validateBoundedString(route.labelOverride, `${routeLabel}.labelOverride`, errors, 256); + validateOptionalBoolean(route.enabled, `${routeLabel}.enabled`, errors); + validateOptionalBoolean(route.supports1m, `${routeLabel}.supports1m`, errors); + + if (route.profileId != null && route.profileId !== '') { + if (!isSafeIdentifier(route.profileId)) errors.push(`${routeLabel}.profileId is invalid`); + else if (profileIds.size > 0 && !profileIds.has(route.profileId)) { + errors.push(`${routeLabel}.profileId does not match any profile`); + } + } + if (route.clientProtocols != null) { + if (!Array.isArray(route.clientProtocols)) { + errors.push(`${routeLabel}.clientProtocols must be an array`); + } else if ( + route.clientProtocols.length > 3 || + route.clientProtocols.some((protocol) => !allowedClientProtocols.has(String(protocol))) + ) { + errors.push(`${routeLabel}.clientProtocols contains unsupported protocol`); + } + } + }); + return { valid: errors.length === 0, errors }; +} + +function validateClaudeDesktopConfig( + claudeDesktop, + profileIds, + errors = [], + label = 'claudeDesktop' +) { + if (claudeDesktop == null) return { valid: errors.length === 0, errors }; + if (!isPlainObject(claudeDesktop)) { + errors.push(`${label} must be an object`); + return { valid: errors.length === 0, errors }; + } + + validateOptionalBoolean(claudeDesktop.enabled, `${label}.enabled`, errors); + if ( + claudeDesktop.mode != null && + !['proxy', 'direct', 'official'].includes(String(claudeDesktop.mode)) + ) { + errors.push(`${label}.mode is unsupported`); + } + validateBoundedString(claudeDesktop.profileName, `${label}.profileName`, errors, 256); + validateBoundedString(claudeDesktop.token, `${label}.token`, errors, 4096); + validateBoundedString(claudeDesktop.directApiKey, `${label}.directApiKey`, errors, 4096); + validateHttpUrl(claudeDesktop.baseUrl, `${label}.baseUrl`, errors); + validateHttpUrl(claudeDesktop.directBaseUrl, `${label}.directBaseUrl`, errors); + if ( + claudeDesktop.traeProcessStyle != null && + !['', 'auto', 'text', 'thinking', 'off'].includes(String(claudeDesktop.traeProcessStyle)) + ) { + errors.push(`${label}.traeProcessStyle is unsupported`); + } + + if (claudeDesktop.models != null && !Array.isArray(claudeDesktop.models)) { + errors.push(`${label}.models must be an array`); + } else if (Array.isArray(claudeDesktop.models)) { + if (claudeDesktop.models.length > 64) { + errors.push(`${label}.models must contain at most 64 models`); + } + claudeDesktop.models.forEach((model, index) => { + const modelLabel = `${label}.models[${index}]`; + if (!isPlainObject(model)) { + errors.push(`${modelLabel} must be an object`); + return; + } + validateBoundedString(model.routeId, `${modelLabel}.routeId`, errors, 256); + validateBoundedString(model.from, `${modelLabel}.from`, errors, 256); + validateBoundedString(model.upstreamModel, `${modelLabel}.upstreamModel`, errors, 256); + validateBoundedString(model.to, `${modelLabel}.to`, errors, 256); + validateBoundedString(model.labelOverride, `${modelLabel}.labelOverride`, errors, 256); + validateOptionalBoolean(model.supports1m, `${modelLabel}.supports1m`, errors); + validateOptionalBoolean(model.enabled, `${modelLabel}.enabled`, errors); + if (model.profileId != null && model.profileId !== '') { + if (!isSafeIdentifier(model.profileId)) errors.push(`${modelLabel}.profileId is invalid`); + else if (profileIds.size > 0 && !profileIds.has(model.profileId)) { + errors.push(`${modelLabel}.profileId does not match any profile`); + } + } + }); + } + return { valid: errors.length === 0, errors }; +} + +function validateAppSettings(appSettings, errors = [], label = 'appSettings') { + if (appSettings == null) return { valid: errors.length === 0, errors }; + if (!isPlainObject(appSettings)) { + errors.push(`${label} must be an object`); + return { valid: errors.length === 0, errors }; + } + for (const key of [ + 'autoStartProxy', + 'closeToTray', + 'showTopMenu', + 'showTrayMenu', + 'showWindowOnTrayCommand', + 'stopProxyOnExit', + 'desktopNotifications', + 'disableLocalProxy', + 'verifyUpstreamTls', + 'conversationAuditEnabled', + 'conversationAuditCaptureContent', + 'performanceMode', + 'launchOnLogin', + ]) { + validateOptionalBoolean(appSettings[key], `${label}.${key}`, errors); + } + return { valid: errors.length === 0, errors }; +} + +function validateProtocolOptions(protocolOptions, errors = [], label = 'protocolOptions') { + if (protocolOptions == null) return { valid: errors.length === 0, errors }; + if (!isPlainObject(protocolOptions)) { + errors.push(`${label} must be an object`); + return { valid: errors.length === 0, errors }; + } + if ( + protocolOptions.thinkingPolicy != null && + !['drop', 'placeholder', 'inject', 'passthrough'].includes( + String(protocolOptions.thinkingPolicy) + ) + ) { + errors.push(`${label}.thinkingPolicy is unsupported`); + } + if ( + protocolOptions.contextWindowPolicy != null && + !['overflow-drop-oldest', 'disabled', 'none'].includes( + String(protocolOptions.contextWindowPolicy) + ) + ) { + errors.push(`${label}.contextWindowPolicy is unsupported`); + } + validateOptionalBoolean( + protocolOptions.preserveClaudeMessageShape, + `${label}.preserveClaudeMessageShape`, + errors + ); + validateOptionalBoolean( + protocolOptions.stableToolUseIds, + `${label}.stableToolUseIds`, + errors + ); + validateOptionalNumber(protocolOptions.maxContextTokens, `${label}.maxContextTokens`, errors, { + min: 0, + max: 10_000_000, + }); + validateOptionalNumber(protocolOptions.maxSseBufferChars, `${label}.maxSseBufferChars`, errors, { + min: 1, + max: 4 * 1024 * 1024, + }); + validateOptionalNumber( + protocolOptions.openaiToGeminiMaxToolResults, + `${label}.openaiToGeminiMaxToolResults`, + errors, + { min: 0, max: 10_000 } + ); + return { valid: errors.length === 0, errors }; +} + function validateMcpHubConfig(mcpHub, errors = [], label = 'mcpHub') { if (!mcpHub) return { valid: errors.length === 0, errors }; + if (!isPlainObject(mcpHub)) { + errors.push(`${label} must be an object`); + return { valid: errors.length === 0, errors }; + } if (!Array.isArray(mcpHub.servers)) errors.push(`${label}.servers must be an array`); if (!Array.isArray(mcpHub.routing)) errors.push(`${label}.routing must be an array`); if (!Array.isArray(mcpHub.servers) || !Array.isArray(mcpHub.routing)) { @@ -134,7 +364,7 @@ function validateMcpHubConfig(mcpHub, errors = [], label = 'mcpHub') { const serverIds = new Set(); mcpHub.servers.forEach((server, i) => { - if (!server || typeof server !== 'object') { + if (!isPlainObject(server)) { errors.push(`${label}.servers[${i}] must be an object`); return; } @@ -145,7 +375,7 @@ function validateMcpHubConfig(mcpHub, errors = [], label = 'mcpHub') { if (serverIds.has(id)) errors.push(`${serverLabel}.id is duplicated`); serverIds.add(id); } - if (!server.url || !isHttpUrl(server.url)) { + if (!server.url || String(server.url).length > 2048 || !isHttpUrl(server.url)) { errors.push(`${serverLabel}.url must be http(s)`); } else { try { @@ -213,11 +443,11 @@ function validateMcpHubConfig(mcpHub, errors = [], label = 'mcpHub') { } if (route.pattern != null && route.pattern !== '') { const pattern = String(route.pattern); - if (pattern.length > 256) errors.push(`${routeLabel}.pattern is too long`); - try { - new RegExp(pattern); - } catch (err) { - errors.push(`${routeLabel}.pattern is invalid`); + const compiled = compileSafeRoutingPattern(pattern); + if (!compiled.ok) { + const detail = + pattern.length > MAX_ROUTING_PATTERN_CHARS ? 'pattern is too long' : compiled.error; + errors.push(`${routeLabel}.${detail}`); } } }); @@ -292,6 +522,13 @@ function validateEntryAuthConfig(entryAuth, errors = [], label = 'entryAuth') { function validateServerConfig(server, label, errors) { if (!server) return; + if (!isPlainObject(server)) { + errors.push(`${label} must be an object`); + return; + } + validateOptionalBoolean(server.enabled, `${label}.enabled`, errors); + validateOptionalBoolean(server.tls, `${label}.tls`, errors); + validateBoundedString(server.name, `${label}.name`, errors, 256); if (server.host && /[\s/\\]/.test(String(server.host))) { errors.push(`${label}.host is invalid`); } @@ -306,6 +543,26 @@ function validateServerConfig(server, label, errors) { if (server.basePath && !String(server.basePath).startsWith('/')) { errors.push(`${label}.basePath must start with /`); } + if (server.basePath && String(server.basePath).length > 1024) { + errors.push(`${label}.basePath is too long`); + } + if ( + server.protocol != null && + !['claude', 'claude-desktop', 'openai-chat', 'openai-responses'].includes( + String(server.protocol) + ) + ) { + errors.push(`${label}.protocol is unsupported`); + } } -module.exports = { validateConfig, validateMcpHubConfig, validateEntryAuthConfig, isLoopbackHost }; +module.exports = { + validateConfig, + validateModelRoutes, + validateClaudeDesktopConfig, + validateAppSettings, + validateProtocolOptions, + validateMcpHubConfig, + validateEntryAuthConfig, + isLoopbackHost, +}; diff --git a/lib/release/artifact-manifest.js b/lib/release/artifact-manifest.js new file mode 100644 index 00000000..db2903ef --- /dev/null +++ b/lib/release/artifact-manifest.js @@ -0,0 +1,40 @@ +'use strict'; + +const path = require('path'); + +function validateArtifactManifest(manifest, filesByName) { + const errors = []; + if (!Array.isArray(manifest) || manifest.length === 0) { + return ['artifact manifest must contain at least one entry']; + } + const seen = new Set(); + for (const entry of manifest) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + errors.push('artifact manifest entry must be an object'); + continue; + } + const file = String(entry.file || ''); + if (!file || path.basename(file) !== file || seen.has(file)) { + errors.push(`artifact manifest has invalid or duplicate file: ${file}`); + continue; + } + seen.add(file); + const actual = filesByName[file]; + if (!actual) { + errors.push(`artifact is missing: ${file}`); + continue; + } + if (!Number.isSafeInteger(entry.bytes) || entry.bytes <= 0 || entry.bytes !== actual.bytes) { + errors.push(`artifact size mismatch: ${file}`); + } + if (!/^[a-f0-9]{64}$/i.test(String(entry.sha256 || '')) || entry.sha256 !== actual.sha256) { + errors.push(`artifact checksum mismatch: ${file}`); + } + } + for (const file of Object.keys(filesByName)) { + if (!seen.has(file)) errors.push(`artifact is not listed in manifest: ${file}`); + } + return errors; +} + +module.exports = { validateArtifactManifest }; diff --git a/lib/test-runtime.js b/lib/test-runtime.js new file mode 100644 index 00000000..45e52146 --- /dev/null +++ b/lib/test-runtime.js @@ -0,0 +1,15 @@ +'use strict'; + +function createIsolatedTestEnv(baseEnv, runtimeDir) { + if (!runtimeDir || typeof runtimeDir !== 'string') { + throw new TypeError('runtimeDir must be a non-empty string'); + } + const env = { ...(baseEnv || {}) }; + delete env.PROXY_CONFIG_DIR; + delete env.PROXY_RUNTIME_DIR; + env.PROXY_DATA_DIR = runtimeDir; + env.NODE_ENV = 'test'; + return env; +} + +module.exports = { createIsolatedTestEnv }; diff --git a/openai-proxy.js b/openai-proxy.js index dd743311..c5662858 100644 --- a/openai-proxy.js +++ b/openai-proxy.js @@ -83,11 +83,8 @@ 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 { createRuntimeSupport } = require('./lib/proxy/runtime-support'); +const { authorizeManagementRequest } = require('./lib/proxy/management-auth'); const { startProxyServers } = require('./lib/proxy/server-lifecycle'); const { @@ -933,6 +930,7 @@ const handleRequestInner = createCoreRequestHandler({ appendAuditRecord, applyCliSessionModelOverride, applyManagedAuthHeaders, + authorizeManagementRequest, authorizeEntryRequest, buildOutgoingHeaders, captureRequestBody, @@ -983,7 +981,6 @@ const handleRequestInner = createCoreRequestHandler({ isOpenAIResponsesPath, isPublicLocalManagementPath, isQuietLocalPollRequest, - isTauriManagementOrigin, loadConfig, log, logModelMapping, @@ -1047,7 +1044,6 @@ if (require.main === module) { handleRequest, installManagementCorsGuard, log, - maskManagementToken, parsePortOrDefault, writeManagementTokenFile, getRuntimeState: () => ({ diff --git a/package-lock.json b/package-lock.json index edcf905f..9ab986b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,14 @@ "dependencies": { "node-forge": "^1.4.0", "pino": "^10.3.1", + "safe-regex2": "^5.1.1", "socks-proxy-agent": "^10.0.0" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@tauri-apps/cli": "^2.11.2", - "eslint": "^9.39.4", + "c8": "^12.0.0", + "eslint": "^10.8.0", "eslint-plugin-unused-imports": "^4.4.1", "husky": "^9.1.7", "lint-staged": "^15.5.2", @@ -23,6 +26,16 @@ "vite": "^6.4.3" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -508,105 +521,89 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^3.1.5" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@humanfs/core": { @@ -675,6 +672,44 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -1248,6 +1283,13 @@ "node": ">= 10" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1255,6 +1297,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1263,9 +1312,9 @@ "license": "MIT" }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", "bin": { @@ -1340,29 +1389,6 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -1373,21 +1399,26 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/braces": { @@ -1403,31 +1434,38 @@ "node": ">=8" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/c8": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz", + "integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^18.0.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" }, "engines": { - "node": ">=10" + "node": "^20.19.0 || ^22.12.0 || >=23" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } } }, "node_modules/cli-cursor": { @@ -1463,26 +1501,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "color-name": "~1.1.4" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=7.0.0" + "node": ">=20" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -1500,10 +1533,10 @@ "node": ">=18" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" }, @@ -1608,6 +1641,16 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1622,33 +1665,33 @@ } }, "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", - "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -1658,8 +1701,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -1667,7 +1709,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -1698,48 +1740,50 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -1907,6 +1951,23 @@ "dev": true, "license": "ISC" }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1922,6 +1983,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -1948,6 +2019,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1961,19 +2050,6 @@ "node": ">=10.13.0" } }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1984,6 +2060,13 @@ "node": ">=8" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", @@ -2020,23 +2103,6 @@ "node": ">= 4" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2122,27 +2188,43 @@ "dev": true, "license": "ISC" }, - "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "argparse": "^2.0.1" + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" } }, "node_modules/json-buffer": { @@ -2278,13 +2360,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -2351,6 +2426,32 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -2399,16 +2500,29 @@ } }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" } }, "node_modules/ms": { @@ -2418,9 +2532,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -2556,19 +2670,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2589,6 +2690,23 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2660,9 +2778,9 @@ "license": "MIT" }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -2680,7 +2798,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2755,16 +2873,6 @@ "node": ">= 12.13.0" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -2798,6 +2906,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -2850,6 +2967,28 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", @@ -2859,6 +2998,19 @@ "node": ">=10" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3048,19 +3200,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3074,6 +3213,21 @@ "node": ">=8" } }, + "node_modules/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/thread-stream": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", @@ -3176,6 +3330,21 @@ "punycode": "^2.1.0" } }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", @@ -3339,6 +3508,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", @@ -3355,6 +3534,44 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 6bafd6b1..01b11f26 100644 --- a/package.json +++ b/package.json @@ -8,14 +8,16 @@ "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:coverage": "c8 --clean --all --include \"lib/proxy/**/*.js\" --exclude \"lib/proxy/trae-*.js\" --exclude \"lib/proxy/handlers.js\" --reporter=json --reporter=text-summary --check-coverage --statements 58 --branches 65 --functions 62 --lines 58 node scripts/run-unit-tests.js && node scripts/check-security-coverage.js", "test:frontend": "node tauri-ui/src/utils.test.js", - "test:rust": "cargo test --manifest-path src-tauri/Cargo.toml", + "test:rust": "npm run release:prepare && 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-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", + "check:ci": "node scripts/check-ci-governance.js", + "verify": "npm run check:syntax && npm run lint && npm run check:ci && npm run release:check && npm run frontend:check && npm run test:coverage && npm run test:frontend && npm run test:thinking && npm run test:env && npm run test:integration && npm run frontend:build && npm run test:rust", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write \"**/*.{js,cjs,mjs,json,md}\" --ignore-path .prettierignore", @@ -38,8 +40,10 @@ "release:portable": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build-portable.ps1" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@tauri-apps/cli": "^2.11.2", - "eslint": "^9.39.4", + "c8": "^12.0.0", + "eslint": "^10.8.0", "eslint-plugin-unused-imports": "^4.4.1", "husky": "^9.1.7", "lint-staged": "^15.5.2", @@ -58,6 +62,7 @@ "dependencies": { "node-forge": "^1.4.0", "pino": "^10.3.1", + "safe-regex2": "^5.1.1", "socks-proxy-agent": "^10.0.0" } } diff --git a/scripts/check-ci-governance.js b/scripts/check-ci-governance.js new file mode 100644 index 00000000..7281d9f2 --- /dev/null +++ b/scripts/check-ci-governance.js @@ -0,0 +1,30 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { validateCiGovernance, validateReleaseWorkflow } = require('../lib/ci-governance'); + +const repoRoot = path.resolve(__dirname, '..'); +const workflowDir = path.join(repoRoot, '.github', 'workflows'); +const workflowFiles = fs + .readdirSync(workflowDir) + .filter((fileName) => /\.ya?ml$/i.test(fileName)) + .sort(); +const workflows = Object.fromEntries( + workflowFiles.map((fileName) => [ + fileName, + fs.readFileSync(path.join(workflowDir, fileName), 'utf8'), + ]) +); +const workflow = Object.values(workflows).join('\n'); +const dependabot = fs.readFileSync(path.join(repoRoot, '.github', 'dependabot.yml'), 'utf8'); +const errors = [ + ...validateCiGovernance(workflow, dependabot), + ...validateReleaseWorkflow(workflows['release.yml']), +]; + +if (errors.length > 0) { + for (const error of errors) console.error(`[ci-governance] ${error}`); + process.exit(1); +} +console.log('[ci-governance] ok'); diff --git a/scripts/check-release-governance.js b/scripts/check-release-governance.js index 1c0c7ac5..e2298821 100644 --- a/scripts/check-release-governance.js +++ b/scripts/check-release-governance.js @@ -41,10 +41,15 @@ for (const scriptName of [ } 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'); +const rustTestScript = String((packageJson.scripts && packageJson.scripts['test:rust']) || ''); +if (!verifyScript.includes('npm run test:rust')) { + errors.push('verify must run the self-contained Rust test gate'); +} +if (!rustTestScript.includes('npm run release:prepare')) { + errors.push('test:rust must prepare release runtime before invoking cargo'); +} +if (!rustTestScript.includes('cargo test --manifest-path src-tauri/Cargo.toml')) { + errors.push('test:rust must run the Tauri Rust test suite'); } if (errors.length > 0) { diff --git a/scripts/check-security-coverage.js b/scripts/check-security-coverage.js new file mode 100644 index 00000000..74f00344 --- /dev/null +++ b/scripts/check-security-coverage.js @@ -0,0 +1,45 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { collectCoverageMetrics } = require('../lib/coverage-metrics'); + +const repoRoot = path.resolve(__dirname, '..'); +const coverageFile = path.join(repoRoot, 'coverage', 'coverage-final.json'); +const securityModules = new Set([ + 'logger.js', + 'management-auth.js', + 'management-utils.js', + 'proxy-transport.js', + 'safe-routing-pattern.js', + 'validator.js', +]); +const thresholds = { + statements: 79, + branches: 70, + functions: 82, + lines: 79, +}; + +if (!fs.existsSync(coverageFile)) { + throw new Error(`Coverage report is missing: ${coverageFile}`); +} +const coverage = JSON.parse(fs.readFileSync(coverageFile, 'utf8')); +const metrics = collectCoverageMetrics(coverage, (filePath) => + securityModules.has(path.basename(filePath)) +); +const failures = []; +for (const [name, minimum] of Object.entries(thresholds)) { + const actual = metrics[name].percentage; + if (actual < minimum) failures.push(`${name} ${actual}% < ${minimum}%`); +} + +if (failures.length > 0) { + throw new Error(`Security boundary coverage failed: ${failures.join(', ')}`); +} +console.log( + '[coverage-security] ok: ' + + Object.entries(metrics) + .map(([name, metric]) => `${name}=${metric.percentage}%`) + .join(', ') +); diff --git a/scripts/collect-tauri-artifacts.js b/scripts/collect-tauri-artifacts.js index b647efab..27b136ec 100644 --- a/scripts/collect-tauri-artifacts.js +++ b/scripts/collect-tauri-artifacts.js @@ -35,6 +35,25 @@ function sha256(filePath) { return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); } +function writeArtifactManifest(outputDir, artifactPaths) { + const manifest = artifactPaths + .map((artifactPath) => ({ + file: path.basename(artifactPath), + bytes: fs.statSync(artifactPath).size, + sha256: sha256(artifactPath), + })) + .sort((left, right) => left.file.localeCompare(right.file)); + fs.writeFileSync( + path.join(outputDir, 'SHA256SUMS.json'), + JSON.stringify(manifest, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(outputDir, 'SHA256SUMS.txt'), + manifest.map((entry) => `${entry.sha256} *${entry.file}`).join('\n') + '\n' + ); + return manifest; +} + function collectTauriArtifacts(repoRoot, cargoTargetDir) { const outputDir = assertSafeReleasePath( repoRoot, @@ -46,20 +65,13 @@ function collectTauriArtifacts(repoRoot, cargoTargetDir) { fs.rmSync(outputDir, { recursive: true, force: true }); fs.mkdirSync(outputDir, { recursive: true }); - const manifest = []; + const collectedPaths = []; 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), - }); + collectedPaths.push(target); } - fs.writeFileSync( - path.join(outputDir, 'SHA256SUMS.json'), - JSON.stringify(manifest, null, 2) + '\n' - ); + const manifest = writeArtifactManifest(outputDir, collectedPaths); return { outputDir, manifest }; } @@ -77,4 +89,6 @@ module.exports = { collectTauriArtifacts, isInstallerFile, resolveCargoTargetDir, + sha256, + writeArtifactManifest, }; diff --git a/scripts/prepare-tauri-dist.js b/scripts/prepare-tauri-dist.js index 78903c16..6fc2b114 100644 --- a/scripts/prepare-tauri-dist.js +++ b/scripts/prepare-tauri-dist.js @@ -11,6 +11,9 @@ const assets = [ 'app-audit.js', 'app-cli.js', 'app-diagnostics.js', + 'app-tool-status.js', + 'app-environment.js', + 'app-mcp.js', 'app.js', ]; diff --git a/scripts/run-unit-tests.js b/scripts/run-unit-tests.js index 9a7cf48b..589eadad 100644 --- a/scripts/run-unit-tests.js +++ b/scripts/run-unit-tests.js @@ -1,7 +1,9 @@ const fs = require('fs'); +const os = require('os'); const path = require('path'); const { spawnSync } = require('child_process'); const { selectTestShard } = require('../lib/test-sharding'); +const { createIsolatedTestEnv } = require('../lib/test-runtime'); const repoRoot = path.resolve(__dirname, '..'); const testDir = path.join(repoRoot, 'test'); @@ -24,13 +26,17 @@ const unitTests = [ 'test/eslint-config.test.js', 'test/runtime-migration.test.js', 'test/release-governance.test.js', + 'test/release-artifact-manifest.test.js', 'test/tauri-release-wiring.test.js', 'test/installer-process-cleanup.test.js', 'test/conversation-audit-utils.test.js', + 'test/coverage-metrics.test.js', + 'test/ci-governance.test.js', 'test/cli-session-routing.test.js', 'test/upstream-request.test.js', 'test/upstream-retry.test.js', 'test/test-sharding.test.js', + 'test/test-runtime.test.js', 'test/proxy-transport.test.js', 'test/core-request-handler.test.js', 'test/server-lifecycle.test.js', @@ -40,6 +46,7 @@ const unitTests = [ 'test/claude-self-test-target.test.js', 'test/route-index.test.js', 'test/route-gemini-utils.test.js', + 'test/route-response-helpers.test.js', 'test/responses-gemini-code-assist.test.js', 'test/route-error-utils.test.js', 'test/errors.test.js', @@ -56,6 +63,7 @@ const unitTests = [ 'test/gateway-ca-trust.test.js', 'test/claude-desktop.test.js', 'test/management-utils.test.js', + 'test/management-auth.test.js', 'test/management-router.test.js', 'test/diagnostics.test.js', 'test/models.test.js', @@ -64,7 +72,9 @@ const unitTests = [ 'test/responses-state.test.js', 'test/responses-protocol.test.js', 'test/routing.test.js', + 'test/safe-routing-pattern.test.js', 'test/protocol-openai.test.js', + 'test/sse-buffer.test.js', 'test/stream-claude.test.js', 'test/tool-loop-guard.test.js', 'test/tools.test.js', @@ -74,6 +84,7 @@ const unitTests = [ 'test/extension-utils.test.js', 'test/extension-sanitize.test.js', 'test/app-ux.test.js', + 'test/app-bridge.test.js', 'test/app-audit.test.js', 'test/app-diagnostics.test.js', 'test/app-cli.test.js', @@ -159,10 +170,17 @@ try { } for (const testFile of shard.tests) { - const result = spawnSync(process.execPath, [testFile], { - cwd: repoRoot, - stdio: 'inherit', - }); + const testRuntimeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hpm-unit-runtime-')); + let result; + try { + result = spawnSync(process.execPath, [testFile], { + cwd: repoRoot, + env: createIsolatedTestEnv(process.env, testRuntimeDir), + stdio: 'inherit', + }); + } finally { + fs.rmSync(testRuntimeDir, { recursive: true, force: true }); + } if (result.status !== 0) { console.error(`[unit] failed: ${testFile}`); process.exit(result.status || 1); diff --git a/scripts/smoke-test-nsis.ps1 b/scripts/smoke-test-nsis.ps1 new file mode 100644 index 00000000..5afa601f --- /dev/null +++ b/scripts/smoke-test-nsis.ps1 @@ -0,0 +1,62 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$InstallerDirectory +) + +$ErrorActionPreference = 'Stop' +$installerRoot = [System.IO.Path]::GetFullPath($InstallerDirectory) +$installer = Get-ChildItem -LiteralPath $installerRoot -File | + Where-Object { $_.Name -match '-setup\.exe$' } | + Select-Object -First 1 +if (-not $installer) { throw "NSIS setup executable not found under $installerRoot" } + +$tempRoot = [System.IO.Path]::GetFullPath( + (Join-Path ([System.IO.Path]::GetTempPath()) "hpm-nsis-smoke-$PID") +) +$installDir = Join-Path $tempRoot 'HTTPS Proxy Manager' +$tempPrefix = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd('\') + '\' +if (-not $tempRoot.StartsWith($tempPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Smoke-test directory must stay under the system temp directory: $tempRoot" +} + +function Invoke-HiddenProcess { + param([string]$FilePath, [string[]]$Arguments, [int]$TimeoutSeconds = 120) + $process = Start-Process -FilePath $FilePath -ArgumentList $Arguments -PassThru -WindowStyle Hidden + if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { + try { $process.Kill($true) } catch {} + throw "Process timed out after $TimeoutSeconds seconds: $FilePath" + } + if ($process.ExitCode -ne 0) { + throw "Process failed with exit code $($process.ExitCode): $FilePath" + } +} + +try { + New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null + Invoke-HiddenProcess $installer.FullName @('/S', "/D=$installDir") + + foreach ($relativePath in @( + 'HTTPS Proxy Manager.exe', + 'openai-proxy.js', + 'lib\proxy\cert.js', + 'runtime\node\node.exe', + 'release-runtime-manifest.json' + )) { + $candidate = Join-Path $installDir $relativePath + if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { + throw "Installed release is missing required file: $relativePath" + } + } + + $uninstaller = Join-Path $installDir 'uninstall.exe' + if (-not (Test-Path -LiteralPath $uninstaller -PathType Leaf)) { + throw "Installed release is missing uninstaller: $uninstaller" + } + Invoke-HiddenProcess $uninstaller @('/S') 120 + Write-Output "NSIS install/uninstall smoke test passed: $($installer.Name)" +} finally { + if (Test-Path -LiteralPath $tempRoot) { + Remove-Item -LiteralPath $tempRoot -Recurse -Force + } +} diff --git a/scripts/sync-frontend.js b/scripts/sync-frontend.js index c8477402..6d470a39 100644 --- a/scripts/sync-frontend.js +++ b/scripts/sync-frontend.js @@ -11,6 +11,9 @@ const filePairs = [ ['tauri-ui/app-audit.js', 'app-audit.js'], ['tauri-ui/app-cli.js', 'app-cli.js'], ['tauri-ui/app-diagnostics.js', 'app-diagnostics.js'], + ['tauri-ui/app-tool-status.js', 'app-tool-status.js'], + ['tauri-ui/app-environment.js', 'app-environment.js'], + ['tauri-ui/app-mcp.js', 'app-mcp.js'], ['tauri-ui/app.js', 'app.js'], ]; const checkOnly = process.argv.includes('--check'); @@ -48,11 +51,23 @@ function validateCanonicalHtml() { const diagnosticsMatch = html.match( /<\/script>/ ); + const toolStatusMatch = html.match( + /<\/script>/ + ); + const environmentMatch = html.match( + /<\/script>/ + ); + const mcpMatch = html.match( + /<\/script>/ + ); const appMatch = html.match(/<\/script>/); const bridgeIndex = bridgeMatch ? bridgeMatch.index : -1; const auditIndex = auditMatch ? auditMatch.index : -1; const cliIndex = cliMatch ? cliMatch.index : -1; const diagnosticsIndex = diagnosticsMatch ? diagnosticsMatch.index : -1; + const toolStatusIndex = toolStatusMatch ? toolStatusMatch.index : -1; + const environmentIndex = environmentMatch ? environmentMatch.index : -1; + const mcpIndex = mcpMatch ? mcpMatch.index : -1; const appIndex = appMatch ? appMatch.index : -1; if ( !hasCss || @@ -60,14 +75,20 @@ function validateCanonicalHtml() { auditIndex < 0 || cliIndex < 0 || diagnosticsIndex < 0 || + toolStatusIndex < 0 || + environmentIndex < 0 || + mcpIndex < 0 || appIndex < 0 || bridgeIndex > auditIndex || auditIndex > cliIndex || cliIndex > diagnosticsIndex || - diagnosticsIndex > appIndex + diagnosticsIndex > toolStatusIndex || + toolStatusIndex > environmentIndex || + environmentIndex > mcpIndex || + mcpIndex > appIndex ) { fail( - 'tauri-ui/electron-ui.html must reference ./app.css, then ./app-bridge.js, then ./app-audit.js, then ./app-cli.js, then ./app-diagnostics.js, then ./app.js' + 'tauri-ui/electron-ui.html must reference ./app.css, then ./app-bridge.js, then ./app-audit.js, then ./app-cli.js, then ./app-diagnostics.js, then ./app-tool-status.js, then ./app-environment.js, then ./app-mcp.js, then ./app.js' ); } if (/]/i.test(html)) { @@ -77,7 +98,7 @@ function validateCanonicalHtml() { } const inlineScripts = html.match( - /<\/script>)(?!\s+defer\s+src=["']\.\/app\.js(?:\?[^"']*)?["']><\/script>)(?!\s+(?:async\s+)?src=["']https:\/\/cdn\.jsdelivr\.net\/npm\/chart\.js["']><\/script>)[\s>]/gi + /<\/script>)(?!\s+defer\s+src=["']\.\/app\.js(?:\?[^"']*)?["']><\/script>)(?!\s+(?:async\s+)?src=["']https:\/\/cdn\.jsdelivr\.net\/npm\/chart\.js["']><\/script>)[\s>]/gi ) || []; if (inlineScripts.length > 0) { fail( diff --git a/scripts/verify-release-artifacts.js b/scripts/verify-release-artifacts.js new file mode 100644 index 00000000..7f9de1f0 --- /dev/null +++ b/scripts/verify-release-artifacts.js @@ -0,0 +1,22 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { sha256, isInstallerFile } = require('./collect-tauri-artifacts'); +const { validateArtifactManifest } = require('../lib/release/artifact-manifest'); + +const repoRoot = path.resolve(__dirname, '..'); +const installerDir = path.join(repoRoot, 'dist', 'release', 'installers'); +const manifestPath = path.join(installerDir, 'SHA256SUMS.json'); +if (!fs.existsSync(manifestPath)) throw new Error(`Release manifest is missing: ${manifestPath}`); + +const filesByName = {}; +for (const entry of fs.readdirSync(installerDir, { withFileTypes: true })) { + if (!entry.isFile() || !isInstallerFile(entry.name)) continue; + const filePath = path.join(installerDir, entry.name); + filesByName[entry.name] = { bytes: fs.statSync(filePath).size, sha256: sha256(filePath) }; +} +const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); +const errors = validateArtifactManifest(manifest, filesByName); +if (errors.length > 0) throw new Error(`Release artifact validation failed: ${errors.join(', ')}`); +console.log(`[release-artifacts] ok: ${manifest.length} artifact(s)`); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 646026e4..c7a745d2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2599,9 +2599,9 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plist" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", @@ -2774,9 +2774,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.39.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] diff --git a/src-tauri/nsis/stop-installed-processes.ps1 b/src-tauri/nsis/stop-installed-processes.ps1 index 7f595267..526b8417 100644 --- a/src-tauri/nsis/stop-installed-processes.ps1 +++ b/src-tauri/nsis/stop-installed-processes.ps1 @@ -153,10 +153,19 @@ function Get-OwnedProcesses { } } } catch { - Write-Output "CIM process lookup unavailable; using process-path lookup: $($_.Exception.Message)" + # This function's success pipeline is its return value. Diagnostics on + # that pipeline are interpreted as owned-process records by callers. + Write-Verbose "CIM process lookup unavailable; using process-path lookup: $($_.Exception.Message)" } - return @($ownedByPid.Values | Sort-Object ProcessId) + # An empty Hashtable.Values collection can surface as a single $null item + # through Windows PowerShell's pipeline. Filter it explicitly so callers do + # not treat "no owned process" as a phantom process with an empty PID/path. + return @( + $ownedByPid.Values | + Where-Object { $_ -and $_.ProcessId } | + Sort-Object ProcessId + ) } function Get-OwnedProcessById { diff --git a/src-tauri/src/codex_login.rs b/src-tauri/src/codex_login.rs new file mode 100644 index 00000000..e1cd7563 --- /dev/null +++ b/src-tauri/src/codex_login.rs @@ -0,0 +1,68 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +pub(crate) const CODEX_LOGIN_SCRIPT_FILE_NAME: &str = "hpm-codex-official-login.ps1"; + +fn user_home_dir() -> Result { + std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .ok_or_else(|| "Unable to resolve the current user home directory".to_string()) +} + +pub(crate) fn codex_login_script_path(home_dir: &Path) -> PathBuf { + home_dir.join(".codex").join(CODEX_LOGIN_SCRIPT_FILE_NAME) +} + +pub(crate) fn validate_codex_login_script_file(path: &Path) -> Result { + let metadata = std::fs::symlink_metadata(path) + .map_err(|_| format!("Codex login script not found: {}", path.display()))?; + if !metadata.is_file() { + return Err("Codex login script must be a regular file".to_string()); + } + if metadata.file_type().is_symlink() { + return Err("Codex login script must not be a symbolic link".to_string()); + } + + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x00000400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err("Codex login script must not be a reparse point".to_string()); + } + } + + path.canonicalize() + .map_err(|err| format!("Failed to resolve Codex login script: {err}")) +} + +#[tauri::command] +pub(crate) fn launch_codex_login_script() -> Result { + let expected_path = codex_login_script_path(&user_home_dir()?); + let path = validate_codex_login_script_file(&expected_path)?; + + let mut command = Command::new("powershell.exe"); + command + .args(["-NoLogo", "-NoProfile", "-NoExit", "-File"]) + .arg(&path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NEW_CONSOLE: u32 = 0x00000010; + command.creation_flags(CREATE_NEW_CONSOLE); + } + + let child = command + .spawn() + .map_err(|err| format!("Failed to launch Codex login script: {err}"))?; + Ok(serde_json::json!({ + "pid": child.id(), + "scriptPath": path + })) +} diff --git a/src-tauri/src/launch_on_login.rs b/src-tauri/src/launch_on_login.rs new file mode 100644 index 00000000..8ac9b9d5 --- /dev/null +++ b/src-tauri/src/launch_on_login.rs @@ -0,0 +1,90 @@ +use std::path::Path; + +pub(crate) const WINDOWS_RUN_VALUE_NAME: &str = "HTTPS Proxy Manager"; + +pub(crate) fn windows_launch_command(exe: &Path) -> String { + format!("\"{}\"", exe.display()) +} + +pub(crate) fn launch_command_matches_exe(value: &str, exe: &Path) -> bool { + value + .trim() + .eq_ignore_ascii_case(&windows_launch_command(exe)) +} + +pub(crate) fn startup_value_matches_exe(value: Option<&str>, exe: &Path) -> bool { + value.is_some_and(|current| launch_command_matches_exe(current, exe)) +} + +#[cfg(windows)] +pub(crate) fn read_launch_on_login() -> Result { + use winreg::enums::{HKEY_CURRENT_USER, KEY_READ}; + use winreg::RegKey; + + let exe = + std::env::current_exe().map_err(|err| format!("current executable unavailable: {err}"))?; + let current_user = RegKey::predef(HKEY_CURRENT_USER); + let run_key = match current_user + .open_subkey_with_flags(r"Software\Microsoft\Windows\CurrentVersion\Run", KEY_READ) + { + Ok(key) => key, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(format!("cannot read Windows startup settings: {err}")), + }; + match run_key.get_value::(WINDOWS_RUN_VALUE_NAME) { + Ok(value) => Ok(startup_value_matches_exe(Some(&value), &exe)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + Ok(startup_value_matches_exe(None, &exe)) + } + Err(err) => Err(format!("cannot read Windows startup entry: {err}")), + } +} + +#[cfg(not(windows))] +pub(crate) fn read_launch_on_login() -> Result { + Err("launch on login is only supported on Windows".to_string()) +} + +#[cfg(windows)] +fn write_launch_on_login(enabled: bool) -> Result { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + + let exe = + std::env::current_exe().map_err(|err| format!("current executable unavailable: {err}"))?; + let current_user = RegKey::predef(HKEY_CURRENT_USER); + let (run_key, _) = current_user + .create_subkey(r"Software\Microsoft\Windows\CurrentVersion\Run") + .map_err(|err| format!("cannot open Windows startup settings: {err}"))?; + if enabled { + run_key + .set_value(WINDOWS_RUN_VALUE_NAME, &windows_launch_command(&exe)) + .map_err(|err| format!("cannot enable launch on login: {err}"))?; + } else { + match run_key.delete_value(WINDOWS_RUN_VALUE_NAME) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(format!("cannot disable launch on login: {err}")), + } + } + read_launch_on_login() +} + +#[cfg(not(windows))] +fn write_launch_on_login(_enabled: bool) -> Result { + Err("launch on login is only supported on Windows".to_string()) +} + +#[tauri::command] +pub(crate) fn get_launch_on_login() -> Result { + Ok(serde_json::json!({ + "supported": cfg!(windows), + "enabled": read_launch_on_login()? + })) +} + +#[tauri::command] +pub(crate) fn set_launch_on_login(enabled: bool) -> Result { + let enabled = write_launch_on_login(enabled)?; + Ok(serde_json::json!({ "supported": cfg!(windows), "enabled": enabled })) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 53da7901..8add6778 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,11 +9,24 @@ use std::time::{Duration, Instant}; use tauri::{Emitter, Manager}; use tauri_plugin_dialog::DialogExt; +mod codex_login; +mod launch_on_login; + +use codex_login::launch_codex_login_script; +#[cfg(test)] +use codex_login::{ + codex_login_script_path, validate_codex_login_script_file, CODEX_LOGIN_SCRIPT_FILE_NAME, +}; +use launch_on_login::{get_launch_on_login, set_launch_on_login}; +#[cfg(test)] +use launch_on_login::{ + launch_command_matches_exe, startup_value_matches_exe, windows_launch_command, +}; + static ZOOM_LEVEL: AtomicU32 = AtomicU32::new(100); static PROXY_STOPPED_FOR_EXIT: AtomicBool = AtomicBool::new(false); static PROXY_AUTOSTART_IN_FLIGHT: AtomicBool = AtomicBool::new(false); const APP_ID: &str = "https-proxy-manager"; -const WINDOWS_RUN_VALUE_NAME: &str = "HTTPS Proxy Manager"; /// Cached project root (the directory containing proxy.bat). static PROJECT_ROOT: OnceLock> = OnceLock::new(); @@ -621,11 +634,11 @@ fn proxy_command_direct(command: &str) -> Option Some(stop_proxy_direct()), "start" => Some(start_proxy_direct()), - "restart" => Some((|| { + "restart" => Some({ let _ = stop_proxy_direct(); std::thread::sleep(Duration::from_millis(250)); start_proxy_direct() - })()), + }), _ => None, } } @@ -797,87 +810,6 @@ fn apply_app_menu(window: tauri::Window, show: bool) -> Result<(), String> { } } -fn windows_launch_command(exe: &Path) -> String { - format!("\"{}\"", exe.display()) -} - -fn launch_command_matches_exe(value: &str, exe: &Path) -> bool { - value - .trim() - .eq_ignore_ascii_case(&windows_launch_command(exe)) -} - -#[cfg(windows)] -fn read_launch_on_login() -> Result { - use winreg::enums::{HKEY_CURRENT_USER, KEY_READ}; - use winreg::RegKey; - - let exe = - std::env::current_exe().map_err(|err| format!("current executable unavailable: {err}"))?; - let current_user = RegKey::predef(HKEY_CURRENT_USER); - let run_key = match current_user - .open_subkey_with_flags(r"Software\Microsoft\Windows\CurrentVersion\Run", KEY_READ) - { - Ok(key) => key, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(err) => return Err(format!("cannot read Windows startup settings: {err}")), - }; - match run_key.get_value::(WINDOWS_RUN_VALUE_NAME) { - Ok(value) => Ok(launch_command_matches_exe(&value, &exe)), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(err) => Err(format!("cannot read Windows startup entry: {err}")), - } -} - -#[cfg(not(windows))] -fn read_launch_on_login() -> Result { - Err("launch on login is only supported on Windows".to_string()) -} - -#[cfg(windows)] -fn write_launch_on_login(enabled: bool) -> Result { - use winreg::enums::HKEY_CURRENT_USER; - use winreg::RegKey; - - let exe = - std::env::current_exe().map_err(|err| format!("current executable unavailable: {err}"))?; - let current_user = RegKey::predef(HKEY_CURRENT_USER); - let (run_key, _) = current_user - .create_subkey(r"Software\Microsoft\Windows\CurrentVersion\Run") - .map_err(|err| format!("cannot open Windows startup settings: {err}"))?; - if enabled { - run_key - .set_value(WINDOWS_RUN_VALUE_NAME, &windows_launch_command(&exe)) - .map_err(|err| format!("cannot enable launch on login: {err}"))?; - } else { - match run_key.delete_value(WINDOWS_RUN_VALUE_NAME) { - Ok(()) => {} - Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} - Err(err) => return Err(format!("cannot disable launch on login: {err}")), - } - } - read_launch_on_login() -} - -#[cfg(not(windows))] -fn write_launch_on_login(_enabled: bool) -> Result { - Err("launch on login is only supported on Windows".to_string()) -} - -#[tauri::command] -fn get_launch_on_login() -> Result { - Ok(serde_json::json!({ - "supported": cfg!(windows), - "enabled": read_launch_on_login()? - })) -} - -#[tauri::command] -fn set_launch_on_login(enabled: bool) -> Result { - let enabled = write_launch_on_login(enabled)?; - Ok(serde_json::json!({ "supported": cfg!(windows), "enabled": enabled })) -} - fn open_path(path: &Path) { let mut command = std::process::Command::new("cmd"); command.arg("/c").arg("start").arg("").arg(path); @@ -912,40 +844,6 @@ fn open_cert_dir() { } } -#[tauri::command] -fn launch_codex_login_script(script_path: String) -> Result { - let path = PathBuf::from(&script_path); - if !path.exists() { - return Err(format!("Codex login script not found: {}", script_path)); - } - if path.extension().and_then(|ext| ext.to_str()).unwrap_or("") != "ps1" { - return Err("Codex login script must be a .ps1 file".to_string()); - } - - let mut command = Command::new("powershell.exe"); - command - .args(["-NoExit", "-ExecutionPolicy", "Bypass", "-File"]) - .arg(&path) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - const CREATE_NEW_CONSOLE: u32 = 0x00000010; - command.creation_flags(CREATE_NEW_CONSOLE); - } - - let child = command - .spawn() - .map_err(|err| format!("Failed to launch Codex login script: {}", err))?; - Ok(serde_json::json!({ - "pid": child.id(), - "scriptPath": script_path - })) -} - #[tauri::command] async fn select_directory_native(app: tauri::AppHandle) -> Result { tauri::async_runtime::spawn_blocking(move || { @@ -1147,111 +1045,6 @@ fn execute_proxy_command_blocking(command: String) -> Result PathBuf { - let dir = - std::env::temp_dir().join(format!("hpm-tauri-root-test-{name}-{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - dir - } - - fn make_runtime_root(path: &Path) { - fs::create_dir_all(path.join("lib")).unwrap(); - fs::write(path.join("proxy.bat"), "@echo off\r\n").unwrap(); - fs::write(path.join("proxy.ps1"), "").unwrap(); - fs::write(path.join("openai-proxy.js"), "").unwrap(); - } - - #[test] - fn normalizes_allowed_proxy_commands() { - assert_eq!(normalize_proxy_command("status").unwrap(), "status"); - assert_eq!(normalize_proxy_command(" START ").unwrap(), "start"); - assert_eq!(normalize_proxy_command("Stop").unwrap(), "stop"); - assert_eq!(normalize_proxy_command("restart").unwrap(), "restart"); - } - - #[test] - fn rejects_unsupported_proxy_commands() { - assert!(normalize_proxy_command("") - .unwrap_err() - .contains("required")); - assert!(normalize_proxy_command("health") - .unwrap_err() - .contains("unsupported")); - assert!(normalize_proxy_command("status & calc") - .unwrap_err() - .contains("unsupported")); - } - - #[test] - fn runtime_root_requires_backend_files() { - let root = unique_temp_dir("incomplete"); - fs::write(root.join("proxy.bat"), "@echo off\r\n").unwrap(); - assert!(!is_proxy_runtime_root(&root)); - - make_runtime_root(&root); - assert!(is_proxy_runtime_root(&root)); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn root_candidate_selection_deduplicates_and_preserves_order() { - let first = unique_temp_dir("first"); - let second = unique_temp_dir("second"); - make_runtime_root(&first); - make_runtime_root(&second); - - let selected = - first_unique_existing_root(vec![first.clone(), first.clone(), second.clone()]); - assert_eq!(selected, Some(first.clone())); - - let _ = fs::remove_dir_all(first); - let _ = fs::remove_dir_all(second); - } - - #[test] - fn startup_command_quotes_executable_paths_and_matches_case_insensitively() { - let exe = Path::new(r"C:\Program Files\HTTPS Proxy Manager\app.exe"); - assert_eq!( - windows_launch_command(exe), - r#""C:\Program Files\HTTPS Proxy Manager\app.exe""# - ); - assert!(launch_command_matches_exe( - r#" "c:\program files\https proxy manager\APP.EXE" "#, - exe - )); - assert!(!launch_command_matches_exe(r#""C:\Other\app.exe""#, exe)); - } - - #[cfg(windows)] - #[test] - fn startup_registry_read_handles_a_missing_value() { - use winreg::enums::HKEY_CURRENT_USER; - use winreg::RegKey; - - let current_user = RegKey::predef(HKEY_CURRENT_USER); - let (run_key, _) = current_user - .create_subkey(r"Software\Microsoft\Windows\CurrentVersion\Run") - .unwrap(); - let previous = run_key.get_raw_value(WINDOWS_RUN_VALUE_NAME).ok(); - let _ = run_key.delete_value(WINDOWS_RUN_VALUE_NAME); - - let result = read_launch_on_login(); - - if let Some(value) = previous { - run_key - .set_raw_value(WINDOWS_RUN_VALUE_NAME, &value) - .unwrap(); - } - assert_eq!(result.unwrap(), false); - } -} - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -1488,9 +1281,9 @@ pub fn run() { #[cfg(debug_assertions)] if let Some(window) = app_handle.get_webview_window("main") { if window.is_devtools_open() { - let _ = window.close_devtools(); + window.close_devtools(); } else { - let _ = window.open_devtools(); + window.open_devtools(); } } } @@ -1521,3 +1314,6 @@ pub fn run() { } }); } + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/tests.rs b/src-tauri/src/tests.rs new file mode 100644 index 00000000..b3dac7ba --- /dev/null +++ b/src-tauri/src/tests.rs @@ -0,0 +1,121 @@ +use super::*; +use std::fs; + +fn unique_temp_dir(name: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("hpm-tauri-root-test-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir +} + +fn make_runtime_root(path: &Path) { + fs::create_dir_all(path.join("lib")).unwrap(); + fs::write(path.join("proxy.bat"), "@echo off\r\n").unwrap(); + fs::write(path.join("proxy.ps1"), "").unwrap(); + fs::write(path.join("openai-proxy.js"), "").unwrap(); +} + +#[test] +fn codex_login_script_path_is_fixed_under_user_home() { + let home = unique_temp_dir("codex-login-home"); + assert_eq!( + codex_login_script_path(&home), + home.join(".codex").join(CODEX_LOGIN_SCRIPT_FILE_NAME) + ); + let _ = fs::remove_dir_all(home); +} + +#[test] +fn codex_login_script_validation_requires_a_regular_file() { + let home = unique_temp_dir("codex-login-validation"); + let script = codex_login_script_path(&home); + fs::create_dir_all(script.parent().unwrap()).unwrap(); + + assert!(validate_codex_login_script_file(&script) + .unwrap_err() + .contains("not found")); + + fs::create_dir(&script).unwrap(); + assert!(validate_codex_login_script_file(&script) + .unwrap_err() + .contains("regular file")); + + fs::remove_dir(&script).unwrap(); + fs::write(&script, "Write-Output 'login'\r\n").unwrap(); + assert_eq!( + validate_codex_login_script_file(&script).unwrap(), + script.canonicalize().unwrap() + ); + let _ = fs::remove_dir_all(home); +} + +#[test] +fn normalizes_allowed_proxy_commands() { + assert_eq!(normalize_proxy_command("status").unwrap(), "status"); + assert_eq!(normalize_proxy_command(" START ").unwrap(), "start"); + assert_eq!(normalize_proxy_command("Stop").unwrap(), "stop"); + assert_eq!(normalize_proxy_command("restart").unwrap(), "restart"); +} + +#[test] +fn rejects_unsupported_proxy_commands() { + assert!(normalize_proxy_command("") + .unwrap_err() + .contains("required")); + assert!(normalize_proxy_command("health") + .unwrap_err() + .contains("unsupported")); + assert!(normalize_proxy_command("status & calc") + .unwrap_err() + .contains("unsupported")); +} + +#[test] +fn runtime_root_requires_backend_files() { + let root = unique_temp_dir("incomplete"); + fs::write(root.join("proxy.bat"), "@echo off\r\n").unwrap(); + assert!(!is_proxy_runtime_root(&root)); + + make_runtime_root(&root); + assert!(is_proxy_runtime_root(&root)); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn root_candidate_selection_deduplicates_and_preserves_order() { + let first = unique_temp_dir("first"); + let second = unique_temp_dir("second"); + make_runtime_root(&first); + make_runtime_root(&second); + + let selected = first_unique_existing_root(vec![first.clone(), first.clone(), second.clone()]); + assert_eq!(selected, Some(first.clone())); + + let _ = fs::remove_dir_all(first); + let _ = fs::remove_dir_all(second); +} + +#[test] +fn startup_command_quotes_executable_paths_and_matches_case_insensitively() { + let exe = Path::new(r"C:\Program Files\HTTPS Proxy Manager\app.exe"); + assert_eq!( + windows_launch_command(exe), + r#""C:\Program Files\HTTPS Proxy Manager\app.exe""# + ); + assert!(launch_command_matches_exe( + r#" "c:\program files\https proxy manager\APP.EXE" "#, + exe + )); + assert!(!launch_command_matches_exe(r#""C:\Other\app.exe""#, exe)); +} + +#[test] +fn startup_value_matching_handles_missing_and_expected_values() { + let exe = Path::new(r"C:\Program Files\HTTPS Proxy Manager\app.exe"); + assert!(!startup_value_matches_exe(None, exe)); + assert!(startup_value_matches_exe( + Some(r#""C:\Program Files\HTTPS Proxy Manager\app.exe""#), + exe + )); +} diff --git a/tauri-ui/app-audit.js b/tauri-ui/app-audit.js index 6f48b534..55e70426 100644 --- a/tauri-ui/app-audit.js +++ b/tauri-ui/app-audit.js @@ -39,7 +39,7 @@ function auditModelFlowLabel(record) { } function formatAuditJson(value, limit = AUDIT_INSPECTOR_PREVIEW_CHARS) { - let text = ''; + let text; if (typeof value === 'string') { text = value; } else { diff --git a/tauri-ui/app-bridge.js b/tauri-ui/app-bridge.js index c06b9320..68c70277 100644 --- a/tauri-ui/app-bridge.js +++ b/tauri-ui/app-bridge.js @@ -48,6 +48,7 @@ let nativeManagementTokenCache = { port: 0, token: '', checkedAt: 0 }; function clearManagementTokenCache() { nativeManagementTokenCache = { port: 0, token: '', checkedAt: 0 }; + // Remove legacy persisted credentials. Management tokens stay in memory only. if (typeof localStorage !== 'undefined') localStorage.removeItem('hpm_mgmt_token'); } @@ -67,10 +68,8 @@ function rememberLocalServerPorts(config) { async function resolveManagementToken(options = {}) { if (options.forceRefresh) clearManagementTokenCache(); - const localToken = - typeof localStorage !== 'undefined' ? localStorage.getItem('hpm_mgmt_token') || '' : ''; const invoke = window.__TAURI__ && window.__TAURI__.core && window.__TAURI__.core.invoke; - if (!invoke) return localToken; + if (!invoke) return ''; const managementPort = getManagementPort(); const now = Date.now(); @@ -90,16 +89,17 @@ async function resolveManagementToken(options = {}) { token: String(result.token), checkedAt: now, }; - localStorage.setItem('hpm_mgmt_token', nativeManagementTokenCache.token); return nativeManagementTokenCache.token; } } catch (err) { console.debug('Native management token unavailable:', err); } - return localToken; + return ''; } +clearManagementTokenCache(); + function setManagementTokenHeader(options, token) { if (!token) return; options.headers = options.headers || {}; @@ -120,9 +120,8 @@ function setManagementTokenHeader(options, token) { const originalFetch = window.fetch; window.fetch = async function (url, options) { const localManagementPath = localManagementPathFromUrl(url); - let needsManagementToken = false; let canRetryWithDirectManagement = false; - if (localManagementPathFromUrl(url)) { + if (localManagementPath) { const origin = window.location.origin; const isRelativeManagementUrl = typeof url === 'string' && (url.startsWith('/v1/local/') || url.startsWith('/local/')); @@ -130,7 +129,7 @@ window.fetch = async function (url, options) { origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:') || origin.startsWith('https://localhost:'); - needsManagementToken = !isRelativeManagementUrl; + let needsManagementToken = !isRelativeManagementUrl; if (isRelativeManagementUrl && !isLocalDevOrigin) { needsManagementToken = true; url = `http://127.0.0.1:${getManagementPort()}${url}`; @@ -274,9 +273,7 @@ if (!window.proxyManager || !window.proxyManager.setActive) { const quiet = options.quiet === true; for (let attempt = 0; attempt < attempts; attempt++) { try { - const res = await fetch(`${getApiBase()}/v1/local/config`, { - headers: { 'x-mgmt-token': localStorage.getItem('hpm_mgmt_token') || '' }, - }); + const res = await fetch(`${getApiBase()}/v1/local/config`); const data = await res.json(); if (!res.ok || data.error) throw new Error(data.error || 'Failed to fetch config'); rememberLocalServerPorts(data); @@ -319,7 +316,6 @@ if (!window.proxyManager || !window.proxyManager.setActive) { method: 'POST', headers: { 'Content-Type': 'application/json', - 'x-mgmt-token': localStorage.getItem('hpm_mgmt_token') || '', }, body: JSON.stringify({ keyId }), }); @@ -1220,9 +1216,9 @@ if (window.__TAURI__ && window.__TAURI__.core) { }; } }; - window.proxyManager.launchCodexLoginScript = async (scriptPath) => { + window.proxyManager.launchCodexLoginScript = async () => { try { - const res = await invoke('launch_codex_login_script', { scriptPath }); + const res = await invoke('launch_codex_login_script'); return { ok: true, ...res }; } catch (e) { console.error('Codex login script launch failed:', e); diff --git a/tauri-ui/app-environment.js b/tauri-ui/app-environment.js new file mode 100644 index 00000000..a7a096ed --- /dev/null +++ b/tauri-ui/app-environment.js @@ -0,0 +1,651 @@ +// Extracted domain module. Loaded before app.js; functions resolve shared globals at call time. + +let isRefreshingEnv = false; +let lastEnvStatusRefresh = 0; +let lastRenderedEnvHtml = ''; +let envPendingRefreshTimer = null; +let lastEnvActionToolIds = []; +const envInstallJobsByTool = new Map(); +const lastEnvToolsById = new Map(); +let envInstallPollTimer = null; + +function getPreferredEnvInstallCommand(tool) { + const toolId = tool && tool.id; + if (toolId === 'claude-code') return 'irm https://claude.ai/install.ps1 | iex'; + if (toolId === 'antigravity-cli') return 'irm https://antigravity.google/cli/install.ps1 | iex'; + if (tool && tool.installCommand) return tool.installCommand; + if (tool && tool.packageName) return `npm i -g ${tool.packageName}@latest`; + return ''; +} + +function scheduleEnvPendingRefresh(delayMs = 2500) { + if (envPendingRefreshTimer) clearTimeout(envPendingRefreshTimer); + envPendingRefreshTimer = setTimeout(() => { + envPendingRefreshTimer = null; + refreshEnvStatus({ passive: true, force: true }); + }, delayMs); +} + +function rememberEnvInstallJobs(results = []) { + let hasRunning = false; + for (const result of results) { + if (!result || !result.jobId || !result.id) continue; + envInstallJobsByTool.set(result.id, { + jobId: result.jobId, + toolId: result.id, + name: result.name || result.id, + status: result.status || 'running', + ok: result.ok, + error: result.error || '', + startedAt: result.startedAt || Date.now(), + endedAt: result.endedAt || null, + installCommand: result.installCommand || '', + usesIrm: !!result.usesIrm, + lastLine: result.lastLine || result.error || result.outputTail || '任务已启动', + outputTail: result.outputTail || '', + }); + if (result.status !== 'succeeded' && result.status !== 'failed') { + hasRunning = true; + } + } + if (hasRunning) startEnvInstallPolling(); + updateEnvInstallProgressPanel(); +} + +function envJobStatusLabel(status) { + if (status === 'succeeded') return '完成'; + if (status === 'failed') return '失败'; + if (status === 'verifying') return '复检中'; + if (status === 'submitting') return '提交中'; + return '执行中'; +} + +function envDataSignature(tools = []) { + return JSON.stringify({ + tools: tools.map((tool) => ({ + id: tool && tool.id, + name: tool && tool.name, + current: tool && tool.current, + latest: tool && tool.latest, + hasUpdate: !!(tool && tool.hasUpdate), + })), + jobs: Array.from(envInstallJobsByTool.values()).map((job) => ({ + toolId: job && job.toolId, + status: job && job.status, + lastLine: job && job.lastLine, + outputTail: job && job.outputTail, + installCommand: job && job.installCommand, + usesIrm: !!(job && job.usesIrm), + })), + }); +} + +function setGridNotice(element) { + if (!element) return; + element.style.gridColumn = '1 / -1'; +} + +function createEnvCodeBlock(text, maxHeight = '42px') { + const code = document.createElement('code'); + code.style.display = 'block'; + code.style.maxHeight = maxHeight; + code.style.overflow = 'auto'; + code.style.whiteSpace = 'pre-wrap'; + code.style.wordBreak = 'break-word'; + code.style.color = 'var(--text)'; + code.style.fontFamily = 'var(--font-mono)'; + code.style.fontSize = '11.5px'; + code.style.lineHeight = '1.35'; + code.textContent = text; + return code; +} + +function createEnvProgressItem(job) { + const failed = job.status === 'failed'; + const succeeded = job.status === 'succeeded'; + const item = document.createElement('div'); + item.style.display = 'grid'; + item.style.gridTemplateColumns = 'minmax(120px, 180px) minmax(100px, 140px) 1fr'; + item.style.gap = '10px'; + item.style.alignItems = 'start'; + item.style.padding = '8px 10px'; + item.style.border = `1px solid ${failed ? 'rgba(239, 68, 68, 0.35)' : 'var(--border)'}`; + item.style.borderRadius = '6px'; + item.style.background = succeeded ? 'rgba(16, 185, 129, 0.08)' : 'var(--card-bg)'; + + const info = document.createElement('div'); + const name = document.createElement('div'); + name.style.fontWeight = '700'; + name.style.color = 'var(--text)'; + name.textContent = job.name || job.toolId || ''; + const method = document.createElement('div'); + method.className = 'muted'; + method.style.fontSize = '12px'; + method.textContent = job.usesIrm ? '官方 irm 安装脚本' : 'npm 全局安装'; + info.append(name, method); + + const status = document.createElement('strong'); + status.style.color = failed ? '#ef4444' : 'var(--text)'; + status.textContent = envJobStatusLabel(job.status); + + item.append( + info, + status, + createEnvCodeBlock( + job.lastLine || job.outputTail || job.installCommand || '等待输出...', + '48px' + ) + ); + return item; +} + +function updateEnvInstallProgressPanel() { + const panel = $('envInstallProgressPanel'); + if (!panel) return; + const jobs = Array.from(envInstallJobsByTool.values()).sort( + (a, b) => (b.startedAt || 0) - (a.startedAt || 0) + ); + if (jobs.length === 0) { + panel.style.display = 'none'; + clearElement(panel); + return; + } + + panel.style.display = 'block'; + clearElement(panel); + const header = document.createElement('div'); + header.style.display = 'flex'; + header.style.alignItems = 'center'; + header.style.justifyContent = 'space-between'; + header.style.gap = '12px'; + header.style.marginBottom = '8px'; + const title = document.createElement('strong'); + title.style.fontSize = '13px'; + title.style.color = 'var(--text)'; + title.textContent = '安装/升级进度'; + const hint = document.createElement('span'); + hint.className = 'muted'; + hint.style.fontSize = '12px'; + hint.textContent = '显示最近任务输出,完成后会自动刷新版本状态'; + header.append(title, hint); + + const list = document.createElement('div'); + list.style.display = 'grid'; + list.style.gap = '8px'; + jobs.forEach((job) => list.appendChild(createEnvProgressItem(job))); + panel.append(header, list); +} + +function markEnvInstallSubmitting(toolIds = []) { + const now = Date.now(); + for (const toolId of toolIds.filter(Boolean)) { + const tool = lastEnvToolsById.get(toolId) || { id: toolId, name: toolId }; + const installCommand = getPreferredEnvInstallCommand(tool); + envInstallJobsByTool.set(toolId, { + jobId: `pending-${toolId}-${now}`, + toolId, + name: tool.name || toolId, + status: 'submitting', + installCommand, + usesIrm: installCommand.startsWith('irm '), + startedAt: now, + lastLine: '正在提交安装任务...', + outputTail: '', + }); + } + updateEnvInstallProgressPanel(); + lastRenderedEnvHtml = ''; +} + +function finalizeEnvInstallSubmission(toolIds = [], results = [], fallbackError = '') { + const byId = new Map((results || []).map((result) => [result && result.id, result])); + for (const toolId of toolIds.filter(Boolean)) { + const result = byId.get(toolId); + if (result && result.jobId) continue; + const current = envInstallJobsByTool.get(toolId); + if (!current || !String(current.jobId || '').startsWith('pending-')) continue; + const failed = fallbackError || (result && result.ok === false); + envInstallJobsByTool.set(toolId, { + ...current, + status: failed ? 'failed' : 'succeeded', + endedAt: Date.now(), + error: fallbackError || (result && result.error) || '', + lastLine: failed + ? fallbackError || (result && result.error) || '安装任务失败' + : '安装/升级完成', + }); + } + updateEnvInstallProgressPanel(); +} + +async function pollEnvInstallJobs() { + const jobIds = Array.from(envInstallJobsByTool.values()) + .filter((job) => job && job.jobId && job.status !== 'succeeded' && job.status !== 'failed') + .map((job) => job.jobId); + if (jobIds.length === 0) { + if (envInstallPollTimer) clearInterval(envInstallPollTimer); + envInstallPollTimer = null; + updateEnvInstallProgressPanel(); + return; + } + + const res = await window.proxyManager.envInstallJobs(jobIds); + if (!res.ok || !Array.isArray(res.jobs)) return; + let changed = false; + let allDone = true; + for (const job of res.jobs) { + if (!job || !job.toolId) continue; + envInstallJobsByTool.set(job.toolId, job); + changed = true; + if (job.status !== 'succeeded' && job.status !== 'failed') allDone = false; + } + if (changed) { + updateEnvInstallProgressPanel(); + lastRenderedEnvHtml = ''; + refreshEnvStatus({ passive: true, force: true }); + } + if (allDone) { + if (envInstallPollTimer) clearInterval(envInstallPollTimer); + envInstallPollTimer = null; + setTimeout(() => refreshEnvStatus({ passive: true, force: true }), 1200); + } +} + +function startEnvInstallPolling() { + if (envInstallPollTimer) return; + envInstallPollTimer = setInterval(() => { + pollEnvInstallJobs().catch((err) => console.error('环境安装进度轮询失败:', err)); + }, 1200); + pollEnvInstallJobs().catch((err) => console.error('环境安装进度轮询失败:', err)); +} + +function createEnvSkeletonCard() { + const card = document.createElement('div'); + card.className = 'card'; + card.style.padding = '16px'; + card.style.display = 'flex'; + card.style.flexDirection = 'column'; + card.style.minHeight = '120px'; + card.style.opacity = '0.7'; + + const top = document.createElement('div'); + top.style.display = 'flex'; + top.style.justifyContent = 'space-between'; + top.style.alignItems = 'flex-start'; + top.style.marginBottom = '12px'; + const identity = document.createElement('div'); + identity.style.display = 'flex'; + identity.style.alignItems = 'center'; + identity.style.gap = '8px'; + const dot = document.createElement('span'); + dot.className = 'status-dot'; + dot.style.margin = '0'; + dot.style.background = 'var(--border)'; + const title = document.createElement('strong'); + title.style.width = '80px'; + title.style.height = '16px'; + title.style.background = 'var(--border)'; + title.style.borderRadius = '2px'; + identity.append(dot, title); + top.appendChild(identity); + + const rows = document.createElement('div'); + rows.style.display = 'flex'; + rows.style.flexDirection = 'column'; + rows.style.gap = '8px'; + ['60%', '80%'].forEach((width) => { + const row = document.createElement('div'); + row.style.display = 'flex'; + row.style.justifyContent = 'space-between'; + row.style.height = '14px'; + row.style.background = 'var(--border)'; + row.style.borderRadius = '2px'; + row.style.width = width; + rows.appendChild(row); + }); + card.append(top, rows); + return card; +} + +function renderEnvSkeleton(grid) { + clearElement(grid); + for (let i = 0; i < 6; i++) grid.appendChild(createEnvSkeletonCard()); +} + +function createEnvActionButton(tool, isInstalled, isJobRunning) { + const button = document.createElement('button'); + button.style.margin = '0'; + button.style.padding = '4px 8px'; + button.style.fontSize = '12px'; + button.style.height = '26px'; + button.style.lineHeight = '1'; + if (isJobRunning) { + button.className = 'light'; + button.disabled = true; + button.style.opacity = '0.7'; + button.textContent = '执行中'; + return button; + } + if (!isInstalled || tool.hasUpdate) { + button.className = 'primary'; + button.dataset.upgradeTool = tool.id || ''; + button.dataset.upgradeAction = isInstalled ? 'upgrade' : 'install'; + button.textContent = isInstalled ? '升级' : '安装'; + return button; + } + button.className = 'light'; + button.disabled = true; + button.style.opacity = '0.5'; + button.textContent = '最新'; + return button; +} + +function createEnvProgressBlock(installJob) { + if (!installJob) return null; + const failed = installJob.status === 'failed'; + const block = document.createElement('div'); + block.style.display = 'flex'; + block.style.flexDirection = 'column'; + block.style.gap = '4px'; + block.style.marginTop = '4px'; + block.style.padding = '8px 10px'; + block.style.border = `1px solid ${failed ? 'rgba(239, 68, 68, 0.35)' : 'var(--border)'}`; + block.style.borderRadius = '6px'; + block.style.background = + installJob.status === 'succeeded' ? 'rgba(16, 185, 129, 0.08)' : 'var(--bg-hover)'; + + const line = document.createElement('div'); + line.style.display = 'flex'; + line.style.justifyContent = 'space-between'; + line.style.gap = '8px'; + line.style.alignItems = 'center'; + const label = document.createElement('span'); + label.className = 'muted'; + label.textContent = '安装进度'; + const status = document.createElement('strong'); + status.style.color = failed ? '#ef4444' : 'var(--text)'; + status.textContent = envJobStatusLabel(installJob.status); + line.append(label, status); + + const method = document.createElement('div'); + method.className = 'muted'; + method.style.fontSize = '12px'; + method.textContent = installJob.usesIrm ? '官方 irm 安装脚本' : 'npm 全局安装'; + block.append( + line, + method, + createEnvCodeBlock(installJob.lastLine || installJob.outputTail || '等待输出...') + ); + return block; +} + +function createEnvInfoRow(labelText, valueText) { + const row = document.createElement('div'); + row.style.display = 'flex'; + row.style.justifyContent = 'space-between'; + const label = document.createElement('span'); + label.className = 'muted'; + label.textContent = labelText; + const value = document.createElement('strong'); + value.style.color = 'var(--text)'; + value.textContent = valueText; + row.append(label, value); + return row; +} + +function createEnvToolCard(tool) { + const isInstalled = !!tool.current; + const installJob = envInstallJobsByTool.get(tool.id); + const isJobRunning = + installJob && installJob.status !== 'succeeded' && installJob.status !== 'failed'; + const card = document.createElement('div'); + card.className = 'card env-card'; + card.style.padding = '16px'; + card.style.display = 'flex'; + card.style.flexDirection = 'column'; + card.style.minHeight = '142px'; + card.style.border = `1px solid ${!isInstalled || tool.hasUpdate ? 'var(--primary)' : 'var(--border)'}`; + card.style.background = + !isInstalled || tool.hasUpdate ? 'rgba(var(--primary-rgb), 0.05)' : 'var(--card-bg)'; + + const header = document.createElement('div'); + header.style.display = 'flex'; + header.style.justifyContent = 'space-between'; + header.style.alignItems = 'flex-start'; + header.style.marginBottom = '12px'; + const identity = document.createElement('div'); + identity.style.display = 'flex'; + identity.style.alignItems = 'center'; + identity.style.gap = '8px'; + const dot = document.createElement('span'); + dot.className = `status-dot ${isInstalled ? 'green' : 'amber'}`; + dot.style.margin = '0'; + const name = document.createElement('strong'); + name.style.fontSize = '15px'; + name.style.color = 'var(--text)'; + name.textContent = tool.name || tool.id || ''; + identity.append(dot, name); + header.append(identity, createEnvActionButton(tool, isInstalled, isJobRunning)); + + const body = document.createElement('div'); + body.style.display = 'flex'; + body.style.flexDirection = 'column'; + body.style.gap = '6px'; + body.style.fontSize = '13px'; + body.style.flex = '1'; + body.append( + createEnvInfoRow('当前版本', tool.current || '未安装'), + createEnvInfoRow('最新版本', tool.latest || '加载中...') + ); + + const commandBlock = document.createElement('div'); + commandBlock.style.display = 'flex'; + commandBlock.style.flexDirection = 'column'; + commandBlock.style.gap = '4px'; + commandBlock.style.marginTop = '4px'; + const commandLabel = document.createElement('span'); + commandLabel.className = 'muted'; + commandLabel.textContent = '安装命令'; + const command = document.createElement('code'); + command.style.display = 'block'; + command.style.padding = '6px 8px'; + command.style.border = '1px solid var(--border)'; + command.style.borderRadius = '4px'; + command.style.background = 'var(--bg-hover)'; + command.style.color = 'var(--text)'; + command.style.fontFamily = 'var(--font-mono)'; + command.style.fontSize = '12px'; + command.style.lineHeight = '1.35'; + command.style.overflowX = 'auto'; + command.style.whiteSpace = 'nowrap'; + command.textContent = getPreferredEnvInstallCommand(tool); + commandBlock.append(commandLabel, command); + body.appendChild(commandBlock); + const progress = createEnvProgressBlock(installJob); + if (progress) body.appendChild(progress); + card.append(header, body); + return card; +} + +async function refreshEnvStatus(options = {}) { + const now = Date.now(); + if ( + options.passive && + !options.force && + lastRenderedEnvHtml && + now - lastEnvStatusRefresh < 30000 + ) + return; + if (isRefreshingEnv) return; + isRefreshingEnv = true; + const finishPerf = perfStart('refresh.envStatus'); + + const grid = $('envGrid'); + const btnRefresh = $('btnRefreshEnv'); + const svgRefresh = $('svgRefreshEnv'); + const btnUpgradeAll = $('btnUpgradeAllEnv'); + + if (btnRefresh) btnRefresh.disabled = true; + if (svgRefresh) svgRefresh.style.animation = 'spin 1s linear infinite'; + + // 预渲染加载骨架;被动刷新已有结果时不闪烁列表。 + if (!options.passive || !lastRenderedEnvHtml) { + renderEnvSkeleton(grid); + } + + try { + const res = await window.proxyManager.envCheck(); + if (!res.ok) throw new Error(res.error || '检查失败'); + + let updateCount = 0; + let installCount = 0; + const actionToolIds = []; + const tools = Array.isArray(res.tools) ? res.tools : []; + tools.forEach((tool) => { + if (tool && tool.id) lastEnvToolsById.set(tool.id, tool); + const isInstalled = !!(tool && tool.current); + if (!isInstalled) installCount++; + if (tool && tool.hasUpdate) updateCount++; + if (tool && (!isInstalled || tool.hasUpdate)) actionToolIds.push(tool.id); + }); + lastEnvActionToolIds = actionToolIds.filter(Boolean); + const envSignature = envDataSignature(tools); + if (envSignature !== lastRenderedEnvHtml) { + grid.replaceChildren(...tools.map((tool) => createEnvToolCard(tool || {}))); + lastRenderedEnvHtml = envSignature; + } + + const actionCount = updateCount + installCount; + btnUpgradeAll.textContent = + actionCount > 0 ? `全部安装/升级 (${actionCount})` : '全部安装/升级 (0)'; + btnUpgradeAll.disabled = actionCount === 0; + lastEnvStatusRefresh = Date.now(); + if (res.latestPending) { + scheduleEnvPendingRefresh(); + } + } catch (err) { + showToast('环境检查刷新失败: ' + err.message, 'error'); + const notice = document.createElement('div'); + notice.className = 'notice'; + setGridNotice(notice); + notice.textContent = `刷新失败,请检查网络或代理服务器运行状态:${err.message}`; + grid.replaceChildren(notice); + } finally { + isRefreshingEnv = false; + if (btnRefresh) btnRefresh.disabled = false; + if (svgRefresh) { + svgRefresh.style.animation = ''; + void svgRefresh.offsetWidth; + } + finishPerf(); + } +} + +window.upgradeSingleTool = async function (toolId, action = 'upgrade') { + const isInstall = action === 'install'; + const confirmed = await confirmAction({ + title: isInstall ? '安装助手工具' : '升级助手工具', + message: `确认要${isInstall ? '安装' : '升级'}该 AI 命令行工具吗?这将在后台执行全局安装命令,耗时可能较长。`, + confirmText: isInstall ? '确定安装' : '确定升级', + }); + if (!confirmed) return; + + markEnvInstallSubmitting([toolId]); + lastRenderedEnvHtml = ''; + refreshEnvStatus({ passive: true, force: true }); + showToast(isInstall ? '正在安装工具,请稍候...' : '正在升级工具,请稍候...', 'success'); + try { + const res = await window.proxyManager.envUpgrade(toolId); + if (!res.ok) throw new Error(res.error || (isInstall ? '安装失败' : '升级失败')); + + const result = res.results && res.results[0]; + if (result && !result.ok) { + throw new Error(result.error || '安装失败'); + } + + rememberEnvInstallJobs(res.results || []); + finalizeEnvInstallSubmission([toolId], res.results || []); + showToast( + isInstall ? '安装任务已启动,正在显示进度...' : '升级任务已启动,正在显示进度...', + 'success' + ); + refreshEnvStatus({ passive: true, force: true }); + } catch (err) { + finalizeEnvInstallSubmission([toolId], [], err.message); + showToast((isInstall ? '工具安装失败:' : '工具升级失败:') + err.message, 'error'); + } +}; + +async function upgradeAllEnv() { + const confirmed = await confirmAction({ + title: '全部安装/升级', + message: + '确认要安装缺失工具并升级有更新的 AI 命令行工具吗?这将在后台依次串行执行全局安装命令,需要数分钟时间。', + confirmText: '全部安装/升级', + }); + if (!confirmed) return; + + showToast('正在执行全部升级,可能需要几分钟时间,请勿关闭本管理器...', 'success'); + const btnUpgradeAll = $('btnUpgradeAllEnv'); + if (btnUpgradeAll) { + btnUpgradeAll.disabled = true; + btnUpgradeAll.textContent = '正在安装/升级...'; + } + const batchToolIds = lastEnvActionToolIds.slice(); + markEnvInstallSubmitting(batchToolIds); + lastRenderedEnvHtml = ''; + refreshEnvStatus({ passive: true, force: true }); + + try { + const res = await window.proxyManager.envUpgrade(batchToolIds); + if (!res.ok) throw new Error(res.error || '升级失败'); + + rememberEnvInstallJobs(res.results || []); + finalizeEnvInstallSubmission(batchToolIds, res.results || []); + const failed = res.results.filter((r) => !r.ok); + if (failed.length > 0) { + showToast(`部分包升级失败: ${failed.map((f) => f.name).join(', ')}`, 'error'); + } else { + showToast('安装/升级任务已启动,正在显示进度...', 'success'); + } + refreshEnvStatus({ passive: true, force: true }); + } catch (err) { + finalizeEnvInstallSubmission(batchToolIds, [], err.message); + showToast('全部升级失败:' + err.message, 'error'); + } finally { + if (btnUpgradeAll) btnUpgradeAll.disabled = false; + } +} + +function bindEnvControls() { + const refreshButton = $('btnRefreshEnv'); + const upgradeAllButton = $('btnUpgradeAllEnv'); + const diagnosticsButton = $('btnDiagnosticsConflicts'); + const grid = $('envGrid'); + + if (refreshButton) { + refreshButton.onclick = () => refreshEnvStatus(); + refreshButton.dataset.bound = 'true'; + } + if (upgradeAllButton) { + upgradeAllButton.onclick = () => upgradeAllEnv(); + upgradeAllButton.dataset.bound = 'true'; + } + if (diagnosticsButton) { + diagnosticsButton.onclick = async () => { + showToast('正在诊断安装冲突与分析包依赖...', 'success'); + setTimeout(() => { + showToast('依赖分析完毕,未检测到包冲突!所有 CLI 工具全局运行环境良好。', 'success'); + }, 1000); + }; + } + if (grid) { + grid.onclick = (event) => { + if (!(event.target instanceof Element)) return; + const button = event.target.closest('[data-upgrade-tool]'); + if (!button || !grid.contains(button)) return; + window.upgradeSingleTool(button.dataset.upgradeTool, button.dataset.upgradeAction); + }; + grid.dataset.bound = 'true'; + } +} diff --git a/tauri-ui/app-mcp.js b/tauri-ui/app-mcp.js new file mode 100644 index 00000000..3e80b2f0 --- /dev/null +++ b/tauri-ui/app-mcp.js @@ -0,0 +1,309 @@ +// Extracted domain module. Loaded before app.js; functions resolve shared globals at call time. + +// --- MCP Hub UI Logic --- + +let mcpHubConfig = { servers: [], routing: [] }; + +const GLM_MCP_TEMPLATES = { + webSearchPrime: { + id: 'glm-web-search', + url: 'https://open.bigmodel.cn/api/mcp/web_search_prime/mcp', + prefix: 'glm_search', + injectAll: true, + }, +}; + +async function initMcpHubView() { + const container = $('mcpHubView'); + if (!container || container.dataset.initialized) { + refreshMcpHub(); + return; + } + container.dataset.initialized = 'true'; + + $('refreshMcpHub').onclick = () => refreshMcpHub(); + $('addMcpServer').onclick = () => addMcpServer(); + const addGlmSearchButton = $('addGlmSearchMcpServer'); + if (addGlmSearchButton) addGlmSearchButton.onclick = () => addGlmSearchMcpServer(); + $('saveMcpHub').onclick = () => saveMcpHub(); + + refreshMcpHub(); +} + +async function refreshMcpHub() { + const status = $('mcpHubStatus'); + status.textContent = '正在加载 MCP 配置...'; + + try { + const res = await window.proxyManager.mcpHubGet(); + if (res.ok) { + mcpHubConfig = res.mcpHub; + renderMcpServers(); + status.textContent = `已加载 ${mcpHubConfig.servers.length} 个服务器,${mcpHubConfig.routing.length} 条路由规则。`; + } else { + status.textContent = '加载 MCP 配置失败:' + (res.error || '未知错误'); + } + + const logsRes = await window.proxyManager.mcpHubLogs(); + if (logsRes.ok) { + renderMcpLogs(logsRes.logs); + } + } catch (err) { + status.textContent = '加载失败:' + err.message; + } +} + +function renderMcpServers() { + const container = $('mcpServersList'); + clearElement(container); + if (!mcpHubConfig || !mcpHubConfig.servers || mcpHubConfig.servers.length === 0) { + container.appendChild(createMcpServersEmptyElement()); + return; + } + + container.appendChild(createMcpServersTableElement(mcpHubConfig.servers)); +} + +function createMcpServersEmptyElement() { + const empty = document.createElement('div'); + empty.className = 'muted'; + empty.style.padding = '20px'; + empty.style.textAlign = 'center'; + empty.textContent = '暂无已配置的 MCP 服务器。'; + return empty; +} + +function createMcpServersTableElement(servers) { + const wrapper = document.createElement('div'); + wrapper.className = 'route-table'; + const head = document.createElement('div'); + head.className = 'route-table-head'; + const headTable = document.createElement('table'); + const thead = document.createElement('thead'); + const headerRow = document.createElement('tr'); + [ + ['服务器 ID', ''], + ['URL (HTTP/SSE)', ''], + ['前缀', ''], + ['Bearer Token', '180px'], + ['注入', '60px'], + ['操作', '80px'], + ].forEach(([label, width]) => { + const th = document.createElement('th'); + th.textContent = label; + if (width) th.style.width = width; + headerRow.appendChild(th); + }); + thead.appendChild(headerRow); + headTable.appendChild(thead); + head.appendChild(headTable); + + const scroll = document.createElement('div'); + scroll.className = 'route-table-scroll'; + const bodyTable = document.createElement('table'); + const tbody = document.createElement('tbody'); + servers.forEach((server, index) => tbody.appendChild(createMcpServerRow(server, index))); + bodyTable.appendChild(tbody); + scroll.appendChild(bodyTable); + wrapper.append(head, scroll); + return wrapper; +} + +function createMcpServerInput(server, index, field, placeholder = '') { + const input = document.createElement('input'); + input.type = field === 'bearerToken' ? 'password' : 'text'; + input.value = String(server[field] || ''); + input.className = 'table-input'; + if (placeholder) input.placeholder = placeholder; + input.addEventListener('change', () => updateMcpServer(index, field, input.value)); + return input; +} + +function createMcpServerRow(server, index) { + const row = document.createElement('tr'); + + const idCell = document.createElement('td'); + idCell.appendChild(createMcpServerInput(server, index, 'id')); + row.appendChild(idCell); + + const urlCell = document.createElement('td'); + urlCell.appendChild(createMcpServerInput(server, index, 'url', 'http://...')); + row.appendChild(urlCell); + + const prefixCell = document.createElement('td'); + prefixCell.appendChild(createMcpServerInput(server, index, 'prefix', '可选')); + row.appendChild(prefixCell); + + const tokenCell = document.createElement('td'); + tokenCell.appendChild(createMcpServerInput(server, index, 'bearerToken', 'GLM Coding Plan Key')); + row.appendChild(tokenCell); + + const injectCell = document.createElement('td'); + injectCell.style.textAlign = 'center'; + const injectCheckbox = document.createElement('input'); + injectCheckbox.type = 'checkbox'; + injectCheckbox.checked = Boolean(server.injectAll); + injectCheckbox.addEventListener('change', () => + updateMcpServer(index, 'injectAll', injectCheckbox.checked) + ); + injectCell.appendChild(injectCheckbox); + row.appendChild(injectCell); + + const actionCell = document.createElement('td'); + actionCell.style.textAlign = 'right'; + const deleteButton = document.createElement('button'); + deleteButton.className = 'danger'; + deleteButton.style.padding = '2px 8px'; + deleteButton.style.fontSize = '12px'; + deleteButton.style.margin = '0'; + deleteButton.textContent = '删除'; + deleteButton.addEventListener('click', () => deleteMcpServer(index)); + actionCell.appendChild(deleteButton); + row.appendChild(actionCell); + + return row; +} + +function updateMcpServer(index, field, value) { + if (mcpHubConfig.servers[index]) { + mcpHubConfig.servers[index][field] = value; + } +} + +function deleteMcpServer(index) { + mcpHubConfig.servers.splice(index, 1); + renderMcpServers(); +} + +function addMcpServer() { + if (!mcpHubConfig.servers) mcpHubConfig.servers = []; + mcpHubConfig.servers.push({ + id: 'mcp-server-' + Math.random().toString(36).substring(2, 6), + url: 'http://localhost:3000/sse', + prefix: '', + injectAll: true, + }); + renderMcpServers(); +} + +function addGlmSearchMcpServer() { + if (!mcpHubConfig.servers) mcpHubConfig.servers = []; + if (!mcpHubConfig.routing) mcpHubConfig.routing = []; + + const template = GLM_MCP_TEMPLATES.webSearchPrime; + const existing = mcpHubConfig.servers.find((server) => server && server.id === template.id); + if (existing) { + Object.assign(existing, { + url: template.url, + prefix: existing.prefix || template.prefix, + injectAll: true, + }); + } else { + mcpHubConfig.servers.push({ + ...template, + bearerToken: '', + }); + } + + const hasRoute = mcpHubConfig.routing.some( + (route) => route && route.serverId === template.id && route.pattern === '^glm_search_' + ); + if (!hasRoute) { + mcpHubConfig.routing.push({ pattern: '^glm_search_', serverId: template.id }); + } + renderMcpServers(); + const status = $('mcpHubStatus'); + if (status) { + status.textContent = + '已添加 GLM 联网搜索 MCP 模板。请在 Bearer Token 中填入 GLM Coding Plan API Key 后保存。'; + } +} + +function createMcpLogsEmptyElement() { + const empty = document.createElement('div'); + empty.className = 'muted'; + empty.textContent = '暂无执行记录...'; + return empty; +} + +function createMcpLogEntryElement(log) { + const entry = document.createElement('div'); + entry.className = 'log-entry'; + entry.style.marginBottom = '12px'; + entry.style.borderBottom = '1px solid var(--border)'; + entry.style.paddingBottom = '8px'; + + const header = document.createElement('div'); + header.style.display = 'flex'; + header.style.justifyContent = 'space-between'; + header.style.alignItems = 'center'; + + const title = document.createElement('div'); + const time = document.createElement('span'); + time.className = 'muted'; + time.textContent = `[${new Date(log.time).toLocaleTimeString()}] `; + title.appendChild(time); + + const server = document.createElement('strong'); + server.style.color = 'var(--text)'; + server.textContent = log.serverId || '-'; + title.appendChild(server); + title.appendChild(document.createTextNode(': ')); + + const tool = document.createElement('code'); + tool.style.color = 'var(--primary)'; + tool.style.fontWeight = '600'; + tool.textContent = log.toolName || '-'; + title.appendChild(tool); + + const badge = document.createElement('span'); + badge.className = `badge ${log.status === 'error' ? 'red' : 'green'}`; + badge.textContent = `${log.status === 'error' ? '失败' : '成功'} (${log.elapsed || 0}ms)`; + + header.appendChild(title); + header.appendChild(badge); + entry.appendChild(header); + + const metadata = document.createElement('div'); + metadata.className = 'muted'; + metadata.style.marginTop = '4px'; + const inputBytes = Number.isFinite(Number(log.inputBytes)) ? Number(log.inputBytes) : 0; + const resultBytes = Number.isFinite(Number(log.resultBytes)) ? Number(log.resultBytes) : 0; + const errorType = log.errorType ? ` · 错误类型 ${log.errorType}` : ''; + metadata.textContent = `输入 ${inputBytes} B · 输出 ${resultBytes} B${errorType}`; + entry.appendChild(metadata); + return entry; +} + +function renderMcpLogs(logs) { + const container = $('mcpExecutionLogs'); + container.textContent = ''; + if (!logs || logs.length === 0) { + container.appendChild(createMcpLogsEmptyElement()); + return; + } + + logs.forEach((log) => container.appendChild(createMcpLogEntryElement(log))); +} + +async function saveMcpHub() { + const btn = $('saveMcpHub'); + const origText = btn.textContent; + btn.disabled = true; + btn.textContent = '正在保存...'; + + try { + const res = await window.proxyManager.mcpHubSave({ mcpHub: mcpHubConfig }); + if (res.ok) { + showToast('MCP 配置已保存', 'success'); + refreshMcpHub(); + } else { + showToast('保存失败:' + (res.error || '未知错误'), 'error'); + } + } catch (err) { + showToast('保存出错:' + err.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = origText; + } +} diff --git a/tauri-ui/app-tool-status.js b/tauri-ui/app-tool-status.js new file mode 100644 index 00000000..bb076c0e --- /dev/null +++ b/tauri-ui/app-tool-status.js @@ -0,0 +1,1138 @@ +// Extracted domain module. Loaded before app.js; functions resolve shared globals at call time. + +function setProxyStatusElement(element, color, text) { + clearElement(element); + const dot = document.createElement('span'); + dot.className = `status-dot ${color}`; + element.append(dot, document.createTextNode(text)); +} + +function isStartupStatusSettling(options = {}) { + if (options.startup === false) return false; + if (config && config.appSettings && config.appSettings.autoStartProxy === false) return false; + return performance.now() - startupStatusStartedAt < STARTUP_STATUS_GRACE_MS; +} + +function renderStartupPortDiagnostics() { + const target = $('heroPortDiagnostics'); + if (target) target.textContent = '端口诊断:等待管理端口启动...'; +} + +function proxyStatusOutputLooksRunning(output) { + const text = String(output || ''); + return ( + /\bRunning\b/i.test(text) || + /\bPID\s*:?\s*\d+\b/i.test(text) || + /Proxy already running/i.test(text) || + /Address:\s*https?:\/\//i.test(text) + ); +} + +function proxyStatusPidFromOutput(output) { + const match = String(output || '').match(/\bPID\s*:?\s*(\d+)\b/i); + return match ? match[1] : ''; +} + +function scheduleStartupProxyRetry() { + if (startupProxyRetryTimer) return; + startupProxyRetryTimer = setTimeout(() => { + startupProxyRetryTimer = null; + if (!isStartupStatusSettling({ startup: true })) return; + lastStatusRefresh = 0; + refreshProxyStatus({ startup: true }).catch((err) => + console.error('startup refreshProxyStatus retry error:', err) + ); + }, STARTUP_STATUS_RETRY_MS); +} + +function deferStartupProxyFailure(statusElement) { + setProxyStatusElement(statusElement, 'amber', '启动中...'); + renderStartupPortDiagnostics(); + scheduleStartupProxyRetry(); +} + +async function refreshProxyStatus(options = {}) { + const now = Date.now(); + const passiveTtl = isMonitorVisible() ? 7000 : 30000; + if (statusRefreshInFlight || (options.passive && now - lastStatusRefresh < passiveTtl)) return; + const finishPerf = perfStart('refresh.proxyStatus'); + statusRefreshInFlight = true; + lastStatusRefresh = now; + const statusElement = $('heroProxyStatus'); + try { + const health = await checkProxyHealth(); + if (health) { + const pid = health.pid ? ` (PID: ${health.pid})` : ''; + setProxyStatusElement(statusElement, 'green', `运行中${pid}`); + renderPortDiagnostics(health); + refreshCertStatusAfterProxyOnline(options); + return; + } + + const result = await window.proxyManager.command('status'); + const output = result.output || ''; + if (proxyStatusOutputLooksRunning(output)) { + const pidValue = proxyStatusPidFromOutput(output); + const pid = pidValue ? ` (PID: ${pidValue})` : ''; + setProxyStatusElement(statusElement, 'green', `运行中${pid}`); + renderPortDiagnostics(null); + refreshCertStatusAfterProxyOnline(options); + return; + } + if (output.trim()) { + if (isStartupStatusSettling(options)) { + deferStartupProxyFailure(statusElement); + return; + } + setProxyStatusElement(statusElement, 'amber', '未运行'); + renderPortDiagnostics(null); + return; + } + if (isStartupStatusSettling(options)) { + deferStartupProxyFailure(statusElement); + return; + } + setProxyStatusElement(statusElement, 'red', '状态未知'); + renderPortDiagnostics(null); + } catch (err) { + // command 调用本身失败时,也用 HTTP 探活兜底 + const health = await checkProxyHealth(); + if (health) { + const pid = health.pid ? ` (PID: ${health.pid})` : ''; + setProxyStatusElement(statusElement, 'green', `运行中${pid}`); + renderPortDiagnostics(health); + refreshCertStatusAfterProxyOnline(options); + return; + } + if (isStartupStatusSettling(options)) { + deferStartupProxyFailure(statusElement); + return; + } + setProxyStatusElement(statusElement, 'red', '检查失败'); + renderPortDiagnostics(null); + } finally { + statusRefreshInFlight = false; + finishPerf(); + } +} + +function renderPortDiagnostics(health) { + const target = $('heroPortDiagnostics'); + if (!target) return; + if (!health || health.appId !== 'https-proxy-manager' || !health.ports) { + target.textContent = '端口诊断:代理未运行或管理端口不可用'; + return; + } + const entries = Array.isArray(health.ports.entries) ? health.ports.entries : []; + const httpsCount = entries.filter((entry) => entry && entry.tls !== false).length; + const httpCount = entries.filter((entry) => entry && entry.tls === false).length; + const entryText = + httpCount > 0 + ? `${httpsCount} 个 HTTPS 入口 + ${httpCount} 个 HTTP 备用入口在线` + : `${httpsCount} 个入口在线`; + const management = health.ports.management ? ` · 管理 ${health.ports.management}` : ''; + target.textContent = `端口诊断:${entryText}${management}`; +} + +async function refreshClaudeDesktopStatus() { + if (!window.proxyManager.claudeDesktopStatus) return; + const badgeEl = $('toolBadge-claude-desktop'); + const dotEl = $('claudeDesktopTitleDot'); + try { + const status = await window.proxyManager.claudeDesktopStatus(); + if (!status.supported) { + $('claudeDesktopStatus').textContent = + `当前平台不可写入 Claude Desktop 配置:${status.error || ''}`; + if (badgeEl) { + badgeEl.textContent = '不支持'; + badgeEl.className = 'badge error'; + } + if (dotEl) dotEl.className = 'status-dot red'; + return; + } + const configured = status.configured ? '已写入 3P profile' : '未写入'; + const actual = status.actualBaseUrl || status.gatewayUrl || '无'; + const expected = status.expectedBaseUrl || '无'; + const desiredMode = status.desiredMode ? `;选择模式:${status.desiredMode}` : ''; + const matchText = status.matchesExpected === false ? ';状态:需重新写入' : ''; + const pathText = + status.profilePath || status.configPath + ? `\nProfile:${status.profilePath || status.configPath}` + : ''; + const evidence = status.clientEvidence || {}; + const activeCode = evidence.activeCodeProcess || {}; + const latestSession = evidence.latestCodeSession || {}; + const clientText = evidence.processCount + ? `\n客户端:Claude PID ${evidence.mainWindowPid || '未知'};Code 模型:${activeCode.model || latestSession.model || '未检测'};CLI Session:${activeCode.cliSessionId || latestSession.cliSessionId || '未检测'}` + : ''; + $('claudeDesktopStatus').textContent = + `${configured};模式:${status.mode}${desiredMode};当前 URL:${actual};预期 URL:${expected}${matchText}${pathText}${clientText}`; + + // Update badge and title dot + if (badgeEl) { + if (status.configured) { + if (status.mode === 'proxy') { + badgeEl.textContent = '已代理'; + badgeEl.className = 'badge active'; + } else if (status.mode === 'direct') { + badgeEl.textContent = '直连'; + badgeEl.className = 'badge direct'; + } else { + badgeEl.textContent = '官方'; + badgeEl.className = 'badge official'; + } + } else { + badgeEl.textContent = '未接管'; + badgeEl.className = 'badge official'; + } + } + if (dotEl) { + if (status.configured && status.mode === 'proxy') { + dotEl.className = 'status-dot green'; + } else if (status.configured && status.mode === 'direct') { + dotEl.className = 'status-dot amber'; + } else { + dotEl.className = 'status-dot'; + } + } + } catch (err) { + $('claudeDesktopStatus').textContent = + `Claude Desktop 状态检查失败:${err && err.message ? err.message : err}`; + if (badgeEl) { + badgeEl.textContent = '失败'; + badgeEl.className = 'badge error'; + } + if (dotEl) dotEl.className = 'status-dot red'; + } +} + +async function refreshClaudeCodeStatus() { + if (!window.proxyManager.claudeCodeStatus) return; + const badgeEl = $('toolBadge-claude-code'); + const dotEl = $('claudeCodeTitleDot'); + try { + const status = await window.proxyManager.claudeCodeStatus(); + if (!status.exists) { + $('claudeCodeStatus').textContent = + `未检测到 Claude Code 配置文件 (路径: ${status.configPath || ''})`; + $('claudeCodeStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '未检测'; + badgeEl.className = 'badge official'; + } + if (dotEl) dotEl.className = 'status-dot'; + return; + } + if (status.baseUrl) { + $('claudeCodeStatus').textContent = + `已配置本地代理;网关地址: ${status.baseUrl},配置路径: ${status.configPath}`; + $('claudeCodeStatus').className = 'notice green'; + if (badgeEl) { + badgeEl.textContent = '已代理'; + badgeEl.className = 'badge active'; + } + if (dotEl) dotEl.className = 'status-dot green'; + } else { + $('claudeCodeStatus').textContent = `官方直连/已启用登录;配置路径: ${status.configPath}`; + $('claudeCodeStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '直连'; + badgeEl.className = 'badge direct'; + } + if (dotEl) dotEl.className = 'status-dot amber'; + } + } catch (err) { + $('claudeCodeStatus').textContent = + `Claude Code 状态检查失败:${err && err.message ? err.message : err}`; + $('claudeCodeStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '失败'; + badgeEl.className = 'badge error'; + } + if (dotEl) dotEl.className = 'status-dot red'; + } +} + +function formatTraeBridgeUrl(status) { + const url = status && (status.bridgeUrl || status.url || status.healthUrl); + if (!url) return 'http://127.0.0.1:17861'; + return String(url).replace(/\/health$/, ''); +} + +function formatTraeCommand(status, key, fallback = '未发现') { + const value = status && status[key]; + return value ? String(value) : fallback; +} + +async function refreshTraeBridgeStatus() { + const badgeEl = $('toolBadge-trae-bridge'); + const dotEl = $('traeBridgeTitleDot'); + const statusEl = $('traeBridgeStatus'); + if (!statusEl) return; + if (!window.proxyManager.traeBridgeStatus) { + statusEl.textContent = '当前版本缺少 Trae Bridge 状态接口。'; + statusEl.className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '不可用'; + badgeEl.className = 'badge error'; + } + if (dotEl) dotEl.className = 'status-dot red'; + return; + } + try { + const status = await window.proxyManager.traeBridgeStatus(); + const reachable = + status && + status.ok !== false && + (status.statusCode === 200 || + Boolean(status.command) || + Boolean(status.waitCommand) || + Boolean(status.streamCommand) || + Boolean(status.rawStreamCommand)); + if (!reachable) { + const reason = status && (status.error || status.message || status.statusCode); + statusEl.textContent = `Trae Bridge 未连接;本地桥接服务通常位于 http://127.0.0.1:17861。${reason ? `原因:${reason}` : '请确认 Trae 已启动并加载扩展。'}`; + statusEl.className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '未连接'; + badgeEl.className = 'badge official'; + } + if (dotEl) dotEl.className = 'status-dot amber'; + return; + } + + const bridgeUrl = formatTraeBridgeUrl(status); + const waitCommand = formatTraeCommand( + status, + 'waitCommand', + formatTraeCommand(status, 'command') + ); + const streamCommand = formatTraeCommand(status, 'streamCommand'); + const rawStreamCommand = formatTraeCommand(status, 'rawStreamCommand'); + const discoveredCount = Number.isFinite(status.discoveredCommandCount) + ? status.discoveredCommandCount + : Array.isArray(status.discoveredCommands) + ? status.discoveredCommands.length + : 0; + const rawAvailable = rawStreamCommand !== '未发现'; + const rawText = rawAvailable ? `Raw 调试流:${rawStreamCommand}` : 'Raw 调试流:未发现'; + + statusEl.textContent = `Trae Bridge 已连接;地址:${bridgeUrl};等待命令:${waitCommand};流式命令:${streamCommand};${rawText};发现命令:${discoveredCount} 个。`; + statusEl.className = 'notice green'; + if (badgeEl) { + badgeEl.textContent = rawAvailable ? '已连接' : '可用'; + badgeEl.className = 'badge active'; + } + if (dotEl) dotEl.className = 'status-dot green'; + refreshTraeDebugSettings(); + } catch (err) { + statusEl.textContent = `Trae Bridge 状态检查失败:${err && err.message ? err.message : err}`; + statusEl.className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '失败'; + badgeEl.className = 'badge error'; + } + if (dotEl) dotEl.className = 'status-dot red'; + } +} + +let lastTraeNativeDebugCommands = [ + 'python scripts\\trae-frida-scan.py', + 'python scripts\\trae-frida-native-probe.py --pid --duration 45', + 'python scripts\\trae-frida-native-probe.py --pid --duration 45', +].join('\n'); + +function renderTraeDebugEvents(events) { + const target = $('traeDebugEvents'); + if (!target) return; + clearElement(target); + if (!Array.isArray(events) || events.length === 0) { + const empty = document.createElement('div'); + empty.className = 'muted'; + empty.textContent = '暂无过程事件。先发起一次 Trae 请求,或启用 Raw Stream Patch 后再刷新。'; + target.appendChild(empty); + return; + } + events + .slice(-40) + .reverse() + .forEach((event) => { + const source = event.source || 'trae'; + const kind = event.kind || event.event || 'event'; + const message = + event.message || + [ + kind, + event.toolName ? `tool=${event.toolName}` : '', + event.toolStatus ? `status=${event.toolStatus}` : '', + event.bodyLen == null ? '' : `body=${event.bodyLen}`, + ] + .filter(Boolean) + .join(' '); + const item = document.createElement('div'); + item.className = 'trae-debug-event'; + const sourceEl = document.createElement('div'); + sourceEl.className = 'trae-debug-event-source'; + sourceEl.textContent = source; + const messageEl = document.createElement('div'); + messageEl.className = 'trae-debug-event-message'; + messageEl.textContent = message; + item.append(sourceEl, messageEl); + target.appendChild(item); + }); +} + +function formatTraeNativeCommandBlock(status, scanSummary) { + const commands = status && status.commands ? status.commands : {}; + const aiPid = scanSummary && scanSummary.aiAgentPid; + const ahaPid = scanSummary && scanSummary.ahaNetPid; + return [ + commands.installFrida || 'python -m pip install --user frida-tools', + commands.scan || 'python scripts\\trae-frida-scan.py', + aiPid + ? `python scripts\\trae-frida-native-probe.py --pid ${aiPid} --duration 45` + : commands.aiAgentHook || + 'python scripts\\trae-frida-native-probe.py --pid --duration 45', + ahaPid + ? `python scripts\\trae-frida-native-probe.py --pid ${ahaPid} --duration 45` + : commands.ahaNetHook || + 'python scripts\\trae-frida-native-probe.py --pid --duration 45', + ].join('\n'); +} + +async function refreshTraeDebugEvents() { + const summaryEl = $('traeDebugSummary'); + if (!summaryEl || !window.proxyManager.traeDebugEvents) return; + summaryEl.textContent = '正在读取 Trae 过程事件...'; + summaryEl.className = 'notice'; + try { + const [eventsResult, nativeStatus] = await Promise.all([ + window.proxyManager.traeDebugEvents(), + window.proxyManager.traeDebugNativeStatus + ? window.proxyManager.traeDebugNativeStatus() + : Promise.resolve(null), + ]); + if (nativeStatus && nativeStatus.commands) { + lastTraeNativeDebugCommands = formatTraeNativeCommandBlock(nativeStatus); + } + if (!eventsResult || eventsResult.ok === false) { + summaryEl.textContent = `Trae Debug 事件读取失败:${eventsResult && eventsResult.error ? eventsResult.error : '未知错误'}`; + summaryEl.className = 'notice'; + renderTraeDebugEvents([]); + return; + } + const rawCount = + eventsResult.raw && Number.isFinite(eventsResult.raw.rawEventCount) + ? eventsResult.raw.rawEventCount + : 0; + const logCount = Number.isFinite(eventsResult.logEventCount) ? eventsResult.logEventCount : 0; + const eventCount = Array.isArray(eventsResult.events) ? eventsResult.events.length : 0; + summaryEl.textContent = `已聚合 ${eventCount} 条安全过程事件;Raw ${rawCount} 条,日志摘要 ${logCount} 条。`; + summaryEl.className = 'notice green'; + renderTraeDebugEvents(eventsResult.events || []); + } catch (err) { + summaryEl.textContent = `Trae Debug 事件读取失败:${err && err.message ? err.message : err}`; + summaryEl.className = 'notice'; + } +} + +function applyTraeDebugSettingsToUi(settings) { + const rawEl = $('traeRawCommentsEnabled'); + const visibleEl = $('traeApiVisibleProgressEnabled'); + const claudeProcessStyleEl = $('traeClaudeProcessStyle'); + const logEl = $('traeLogProgressEnabled'); + if (rawEl) rawEl.checked = Boolean(settings && settings.rawCommentsEnabled); + if (visibleEl) visibleEl.checked = Boolean(settings && settings.apiVisibleProgressEnabled); + if (claudeProcessStyleEl) + claudeProcessStyleEl.value = + settings && settings.claudeProcessProgressEnabled + ? settings.claudeProcessStyle || 'thinking' + : ''; + if (logEl) logEl.checked = settings ? settings.liveLogProgress !== false : true; +} + +function collectTraeDebugSettingsFromUi() { + const visible = Boolean( + $('traeApiVisibleProgressEnabled') && $('traeApiVisibleProgressEnabled').checked + ); + return { + rawCommentsEnabled: + visible || Boolean($('traeRawCommentsEnabled') && $('traeRawCommentsEnabled').checked), + apiVisibleProgressEnabled: visible, + claudeProcessStyle: + $('traeClaudeProcessStyle') && $('traeClaudeProcessStyle').value + ? $('traeClaudeProcessStyle').value + : '', + liveLogProgress: !( + $('traeLogProgressEnabled') && $('traeLogProgressEnabled').checked === false + ), + }; +} + +async function refreshTraeDebugSettings() { + if (!window.proxyManager.traeDebugSettings) return; + try { + const settings = await window.proxyManager.traeDebugSettings(); + if (settings && settings.ok !== false) applyTraeDebugSettingsToUi(settings); + } catch (err) { + // Debug switches are optional; the status banner will show bridge health separately. + } +} + +async function saveTraeDebugSettingsFromUi() { + const summaryEl = $('traeDebugSummary'); + if (!window.proxyManager.saveTraeDebugSettings) return; + const payload = collectTraeDebugSettingsFromUi(); + if ($('traeRawCommentsEnabled')) $('traeRawCommentsEnabled').checked = payload.rawCommentsEnabled; + if (summaryEl) { + summaryEl.textContent = '正在保存 Trae Debug 开关...'; + summaryEl.className = 'notice'; + } + try { + const settings = await window.proxyManager.saveTraeDebugSettings(payload); + if (!settings || settings.ok === false) + throw new Error((settings && settings.error) || '保存失败'); + applyTraeDebugSettingsToUi(settings); + const msg = settings.claudeProcessProgressEnabled + ? settings.claudeProcessStyle === 'text' + ? 'Trae Claude 文本备用过程块已开启;后续 Claude 流式 API 会把 Trae 过程写成独立文本块。' + : 'Trae Claude Thinking 过程块已开启;后续 Claude 流式 API 会尝试把 Trae 过程写入 thinking 区域。' + : settings.apiVisibleProgressEnabled + ? 'Trae API 可见过程已开启;后续流式 API 会把安全过程摘要写入 assistant 正文。' + : 'Trae Debug 开关已保存;API 正文不再注入可见过程摘要。'; + if (summaryEl) { + summaryEl.textContent = msg; + summaryEl.className = + settings.apiVisibleProgressEnabled || settings.claudeProcessProgressEnabled + ? 'notice green' + : 'notice'; + } + $('output').textContent = msg; + showToast(msg, 'success'); + } catch (err) { + const msg = `保存 Trae Debug 开关失败:${err && err.message ? err.message : err}`; + if (summaryEl) summaryEl.textContent = msg; + showToast(msg, 'error'); + } +} + +function formatTraeClaudeProcessSelfTest(result) { + if (!result || result.ok === false) { + return `Claude 过程输出自检失败:${result && result.error ? result.error : '未知错误'}`; + } + const summary = result.summary || {}; + const lines = [ + `pass=${Boolean(result.pass)} status=${result.statusCode || ''} source=${result.source || ''}`, + `model=${(result.request && result.request.model) || ''} expectedStyle=${result.expectedStyle || ''}`, + `debugMode=${(result.settings && result.settings.rawProgressMode) || ''} debugStyle=${(result.settings && result.settings.claudeProcessStyle) || ''}`, + `endpoint=${result.endpoint || ''}`, + `thinking_delta=${summary.thinkingDeltaCount || 0} signature_delta=${summary.signatureDeltaCount || 0} text_delta=${summary.textDeltaCount || 0}`, + `hasTraeProcess=${Boolean(summary.hasTraeProcessText)} planItem=${Boolean(summary.hasTraePlanItem)} timing=${Boolean(summary.hasTraeTiming)} messageStop=${Boolean(summary.hasDone)}`, + result.hint || '', + ]; + return lines.filter(Boolean).join('\n'); +} + +async function runClaudeDesktopProcessSelfTest({ + summaryEl, + outputEl, + runningText, + successText, +} = {}) { + if (!window.proxyManager.traeDebugClaudeProcessSelfTest) return; + if (summaryEl) { + summaryEl.textContent = + runningText || + '正在验证 Claude Desktop 过程输出;会通过 Desktop Gateway 发起一条短 Trae 流式请求...'; + summaryEl.className = 'notice'; + } + if (outputEl) outputEl.textContent = 'Claude 过程输出自检运行中...'; + try { + const result = await window.proxyManager.traeDebugClaudeProcessSelfTest({ + useClaudeDesktopGateway: true, + prompt: '请只回复 OK。', + timeoutMs: 90000, + }); + const text = formatTraeClaudeProcessSelfTest(result); + if (outputEl) outputEl.textContent = text; + if (summaryEl) { + summaryEl.textContent = + result && result.pass + ? successText || + 'Claude Desktop 过程输出自检通过;Desktop Gateway SSE 已包含 Trae 过程事件。' + : text; + summaryEl.className = result && result.pass ? 'notice green' : 'notice'; + } + showToast( + result && result.pass ? 'Claude 过程输出自检通过。' : 'Claude 过程输出自检未通过。', + result && result.pass ? 'success' : 'error' + ); + } catch (err) { + const msg = `Claude 过程输出自检失败:${err && err.message ? err.message : err}`; + if (summaryEl) summaryEl.textContent = msg; + if (outputEl) outputEl.textContent = msg; + showToast(msg, 'error'); + } +} + +async function testTraeClaudeProcessOutput() { + await runClaudeDesktopProcessSelfTest({ + summaryEl: $('traeDebugSummary'), + outputEl: $('traeNativeDeepDebugOutput') || $('traeNativeDebugOutput'), + }); +} + +async function testClaudeDesktopProcessOutput() { + await runClaudeDesktopProcessSelfTest({ + summaryEl: $('claudeDesktopStatus'), + outputEl: $('output'), + runningText: + '正在验证 Claude Desktop 过程显示;会通过当前 Desktop Gateway 跑一条短 Trae 流式请求...', + successText: '过程显示自检通过;Claude Desktop Gateway SSE 已包含 Trae 过程文本。', + }); +} + +function firstClaudeDesktopTraeRouteId() { + ensureClaudeDesktop(); + for (const model of config.claudeDesktop.models || []) { + const resolved = resolveClaudeDesktopModel(model); + const profile = config.profiles.find((item) => item.id === resolved.profileId) || {}; + const target = String(profile.targetUrl || '').toLowerCase(); + if ( + profile.protocol === 'trae' || + resolved.profileId === 'trae-official' || + target.includes('coresg-normal.trae.ai') + ) { + return resolved.routeId; + } + } + const first = config.claudeDesktop.models && config.claudeDesktop.models[0]; + return first ? routeIdOfClaudeDesktopModel(first) : ''; +} + +async function copyClaudeDesktopProcessPrompt() { + let copied = false; + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(CLAUDE_DESKTOP_PROCESS_VERIFICATION_PROMPT); + copied = true; + } + } catch (err) { + copied = false; + } + const routeId = firstClaudeDesktopTraeRouteId(); + const msg = [ + copied ? 'Claude Desktop 验证提示已复制。' : 'Claude Desktop 验证提示如下。', + routeId ? `在 Claude Desktop 里选择模型 ${routeId},粘贴后发送。` : '', + '如果窗口里出现 [Trae plan_item]、[Trae timing] 或类似过程文本,就说明客户端可见链路已打通。', + '', + CLAUDE_DESKTOP_PROCESS_VERIFICATION_PROMPT, + ] + .filter(Boolean) + .join('\n'); + $('output').textContent = msg; + showDiagnosticsView('outputPanel'); + showToast(copied ? 'Claude Desktop 验证提示已复制' : '已显示 Claude Desktop 验证提示', 'success'); +} + +async function scanTraeNativeDebug() { + const outputEl = $('traeNativeDebugOutput'); + const summaryEl = $('traeDebugSummary'); + if (!outputEl || !window.proxyManager.traeDebugNativeScan) return; + outputEl.textContent = '正在扫描 Trae Native PID...'; + if (summaryEl) { + summaryEl.textContent = 'Native 扫描运行中;这是一次性 Frida attach 扫描。'; + summaryEl.className = 'notice'; + } + try { + const [status, scan] = await Promise.all([ + window.proxyManager.traeDebugNativeStatus + ? window.proxyManager.traeDebugNativeStatus() + : Promise.resolve(null), + window.proxyManager.traeDebugNativeScan(), + ]); + const scanSummary = scan && scan.summary ? scan.summary : {}; + if (status && status.commands) { + lastTraeNativeDebugCommands = formatTraeNativeCommandBlock(status, scanSummary); + } + if (scan && scan.running) { + outputEl.textContent = [ + 'Native 扫描已启动,后台运行中。', + scan.summary && scan.summary.ahaNetPid + ? `已缓存 aha_net PID: ${scan.summary.ahaNetPid}` + : '等待扫描结果...', + '', + scan.output || '', + ].join('\n'); + if (summaryEl) { + summaryEl.textContent = 'Native PID 扫描后台运行中;稍后会自动刷新。'; + summaryEl.className = 'notice'; + } + setTimeout(scanTraeNativeDebug, 13000); + return; + } + const lines = [ + scan && scan.ok + ? 'Native 扫描完成。' + : `Native 扫描失败:${(scan && (scan.error || scan.status)) || '未知错误'}`, + `ai_agent PID: ${scanSummary.aiAgentPid || '未发现'}`, + `aha_net PID: ${scanSummary.ahaNetPid || '未发现'}`, + '', + '候选进程:', + ...(Array.isArray(scanSummary.candidates) && scanSummary.candidates.length + ? scanSummary.candidates.map((item) => `- ${item.pid}: ${item.modules.join(', ')}`) + : ['- 未发现 native 候选']), + '', + '脱敏扫描输出:', + scan && scan.output ? scan.output : '', + ]; + outputEl.textContent = lines.join('\n'); + if (summaryEl) { + summaryEl.textContent = + scan && scan.ok + ? 'Native PID 扫描完成;可复制 Hook 命令进行短时本机 Debug。' + : 'Native PID 扫描未完成;可检查 Frida/Python 状态。'; + summaryEl.className = scan && scan.ok ? 'notice green' : 'notice'; + } + } catch (err) { + outputEl.textContent = `Native 扫描失败:${err && err.message ? err.message : err}`; + if (summaryEl) summaryEl.textContent = outputEl.textContent; + } +} + +function formatTraeNativeDeepOutput(result) { + if (!result || result.ok === false) { + return `Native 深入事件读取失败:${result && result.error ? result.error : '未知错误'}`; + } + const counts = result.counts && typeof result.counts === 'object' ? result.counts : {}; + const countLines = Object.keys(counts) + .sort() + .map((key) => `- ${key}: ${counts[key]}`); + const events = Array.isArray(result.events) ? result.events : []; + const eventLines = events.slice(-36).map((event) => { + const keys = + Array.isArray(event.keys) && event.keys.length ? ` keys=${event.keys.join(',')}` : ''; + const sse = + Array.isArray(event.sseEventNames) && event.sseEventNames.length + ? ` sse=${event.sseEventNames.join(',')}` + : ''; + const claude = + Array.isArray(event.sseSemantics) && event.sseSemantics.length + ? ` claude=${event.sseSemantics + .map((item) => item.claudeCandidate) + .filter(Boolean) + .filter((item, index, arr) => arr.indexOf(item) === index) + .join(',')}` + : ''; + const signals = + event.contains && typeof event.contains === 'object' + ? Object.keys(event.contains) + .filter((key) => event.contains[key]) + .join(',') + : ''; + return [ + `- ${event.kind || event.event || 'event'}`, + event.urlPath ? ` url=${event.urlPath}` : '', + event.bytesRead == null ? '' : ` bytes=${event.bytesRead}`, + sse, + claude, + signals ? ` signals=${signals}` : '', + keys, + ] + .filter(Boolean) + .join(''); + }); + return [ + `状态: ${result.status || 'unknown'}${result.running ? ' (running)' : ''}`, + `PID: ${result.pid || '未选择'},模式: ${result.mode || '未启动'},事件: ${result.eventCount || 0}`, + result.error ? `错误: ${result.error}` : '', + result.warning ? `提示: ${result.warning}` : '', + '', + '计数:', + ...(countLines.length ? countLines : ['- 暂无']), + '', + '最近事件:', + ...(eventLines.length ? eventLines : ['- 暂无;启动后发起一次 Trae API 请求再刷新。']), + ] + .filter((line) => line !== '') + .join('\n'); +} + +function waitMs(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getTraeAhaPidFromScan(scan) { + if (scan && scan.summary && scan.summary.ahaNetPid) return Number(scan.summary.ahaNetPid); + const candidates = + scan && scan.summary && Array.isArray(scan.summary.candidates) ? scan.summary.candidates : []; + const candidate = candidates.find( + (item) => Array.isArray(item.modules) && item.modules.includes('aha_net.dll') + ); + return candidate ? Number(candidate.pid) : 0; +} + +async function ensureTraeNativePidReady(outputEl, summaryEl) { + if (!window.proxyManager.traeDebugNativeScan) { + throw new Error('Trae Native PID 扫描接口不可用'); + } + let lastScan = null; + for (let attempt = 0; attempt < 16; attempt += 1) { + lastScan = await window.proxyManager.traeDebugNativeScan(); + const pid = getTraeAhaPidFromScan(lastScan); + if (pid) return { pid, scan: lastScan }; + const running = lastScan && lastScan.running; + const line = running + ? `正在自动扫描 Native PID... ${attempt + 1}/16` + : `正在准备 Native PID... ${attempt + 1}/16`; + if (outputEl) { + outputEl.textContent = [ + line, + lastScan && lastScan.output ? lastScan.output : '等待扫描结果...', + ].join('\n\n'); + } + if (summaryEl) { + summaryEl.textContent = line; + summaryEl.className = 'notice'; + } + await waitMs(attempt < 2 ? 1800 : 2500); + } + const error = lastScan && (lastScan.error || lastScan.status); + throw new Error( + error ? `Native PID 扫描未完成:${error}` : '未发现 Trae aha_net PID,请确认 Trae 正在运行' + ); +} + +async function refreshTraeNativeDeepDebug() { + const outputEl = $('traeNativeDeepDebugOutput'); + const summaryEl = $('traeDebugSummary'); + if (!outputEl || !window.proxyManager.traeDebugNativeDeepEvents) return null; + try { + const result = await window.proxyManager.traeDebugNativeDeepEvents(); + outputEl.textContent = formatTraeNativeDeepOutput(result); + if (summaryEl && result && result.ok !== false) { + summaryEl.textContent = result.running + ? `Native 深入探针运行中;已捕获 ${result.eventCount || 0} 条脱敏结构事件。` + : `Native 深入探针状态:${result.status || 'idle'};已捕获 ${result.eventCount || 0} 条脱敏结构事件。`; + summaryEl.className = result.eventCount ? 'notice green' : 'notice'; + } + return result; + } catch (err) { + outputEl.textContent = `Native 深入事件读取失败:${err && err.message ? err.message : err}`; + if (summaryEl) summaryEl.textContent = outputEl.textContent; + return null; + } +} + +async function startTraeNativeDeepDebug() { + const outputEl = $('traeNativeDeepDebugOutput'); + const summaryEl = $('traeDebugSummary'); + if (!window.proxyManager.startTraeDebugNativeDeep) return; + if (outputEl) outputEl.textContent = '正在一键启用 Native 深入探针...'; + if (summaryEl) { + summaryEl.textContent = + '正在自动扫描 PID 并启动深入探针;这是短时 Frida attach,只用于本机诊断。'; + summaryEl.className = 'notice'; + } + try { + const ready = await ensureTraeNativePidReady(outputEl, summaryEl); + if (outputEl) outputEl.textContent = `已找到 aha_net PID ${ready.pid},正在启动深入探针...`; + const result = await window.proxyManager.startTraeDebugNativeDeep({ + pid: ready.pid, + durationSeconds: 60, + maxPreview: 360, + }); + if (!result || result.ok === false) { + const msg = `Native 深入探针启动失败:${result && result.error ? result.error : '未知错误'}`; + if (outputEl) outputEl.textContent = msg; + if (summaryEl) summaryEl.textContent = msg; + showToast(msg, 'error'); + return; + } + if (outputEl) outputEl.textContent = formatTraeNativeDeepOutput(result); + const msg = 'Native 深入探针已启动;现在发起一次 Trae API 请求,然后刷新深入事件。'; + if (summaryEl) { + summaryEl.textContent = msg; + summaryEl.className = 'notice green'; + } + showToast(msg, 'success'); + setTimeout(refreshTraeNativeDeepDebug, 2500); + } catch (err) { + const msg = `Native 深入探针启动失败:${err && err.message ? err.message : err}`; + if (outputEl) outputEl.textContent = msg; + if (summaryEl) summaryEl.textContent = msg; + showToast(msg, 'error'); + } +} + +async function stopTraeNativeDeepDebug() { + if (!window.proxyManager.stopTraeDebugNativeDeep) return; + try { + const result = await window.proxyManager.stopTraeDebugNativeDeep(); + const outputEl = $('traeNativeDeepDebugOutput'); + if (outputEl) outputEl.textContent = formatTraeNativeDeepOutput(result); + showToast('Native 深入探针停止请求已发送。', 'success'); + } catch (err) { + showToast(`Native 深入探针停止失败:${err && err.message ? err.message : err}`, 'error'); + } +} + +async function refreshAntigravityCliStatus() { + if (!window.proxyManager.antigravityCliStatus) return; + const badgeEl = $('toolBadge-antigravity-cli'); + const dotEl = $('antigravityCliTitleDot'); + try { + const status = await window.proxyManager.antigravityCliStatus(); + if (!status.exists) { + $('antigravityCliStatus').textContent = + `未检测到 Antigravity CLI 配置文件 (路径: ${status.configPath || ''})`; + $('antigravityCliStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '未检测'; + badgeEl.className = 'badge official'; + } + if (dotEl) dotEl.className = 'status-dot'; + return; + } + if (status.baseUrl) { + $('antigravityCliStatus').textContent = + `已配置本地代理;网关地址: ${status.baseUrl},配置路径: ${status.configPath}`; + $('antigravityCliStatus').className = 'notice green'; + if (badgeEl) { + badgeEl.textContent = '已代理'; + badgeEl.className = 'badge active'; + } + if (dotEl) dotEl.className = 'status-dot green'; + } else { + $('antigravityCliStatus').textContent = `官方直连/未配置;配置路径: ${status.configPath}`; + $('antigravityCliStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '直连'; + badgeEl.className = 'badge direct'; + } + if (dotEl) dotEl.className = 'status-dot amber'; + } + } catch (err) { + $('antigravityCliStatus').textContent = + `Antigravity CLI 状态检查失败:${err && err.message ? err.message : err}`; + $('antigravityCliStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '失败'; + badgeEl.className = 'badge error'; + } + if (dotEl) dotEl.className = 'status-dot red'; + } +} + +async function refreshGeminiCliStatus() { + if (!window.proxyManager.geminiCliStatus) return; + const badgeEl = $('toolBadge-gemini-cli'); + const dotEl = $('geminiCliTitleDot'); + try { + const status = await window.proxyManager.geminiCliStatus(); + if (!status.exists) { + $('geminiCliStatus').textContent = + `未检测到 Google Gemini CLI 配置文件 (路径: ${status.configPath || ''})`; + $('geminiCliStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '未检测'; + badgeEl.className = 'badge official'; + } + if (dotEl) dotEl.className = 'status-dot'; + return; + } + if (status.apiEndpoint || status.envBaseUrl) { + const ep = status.apiEndpoint || status.envBaseUrl; + $('geminiCliStatus').textContent = + `已配置本地代理;网关地址: ${ep},配置路径: ${status.configPath}`; + $('geminiCliStatus').className = 'notice green'; + if (badgeEl) { + badgeEl.textContent = '已代理'; + badgeEl.className = 'badge active'; + } + if (dotEl) dotEl.className = 'status-dot green'; + } else { + $('geminiCliStatus').textContent = `官方直连/已启用认证;配置路径: ${status.configPath}`; + $('geminiCliStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '直连'; + badgeEl.className = 'badge direct'; + } + if (dotEl) dotEl.className = 'status-dot amber'; + } + } catch (err) { + $('geminiCliStatus').textContent = + `Google Gemini CLI 状态检查失败:${err && err.message ? err.message : err}`; + $('geminiCliStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '失败'; + badgeEl.className = 'badge error'; + } + if (dotEl) dotEl.className = 'status-dot red'; + } +} + +function formatCodexAuthSummary(status) { + const labels = { + chatgpt: '官方登录', + api: 'API 登录', + mixed: '官方 token + 用户 API Key', + 'legacy-managed-api-key': '旧版代理占位 Key', + none: '未登录', + }; + const label = labels[status && status.authMode] || '未知'; + const notes = []; + if (status && status.authConflict) { + notes.push('Codex 运行时仍会二选一,建议切换前清理不需要的凭据'); + } + if (status && status.hasManagedApiKey) { + notes.push('检测到旧版代理占位 Key,恢复直连会自动清理'); + } + if (status && status.hasProxyManagedApiKey) { + notes.push('本地代理占位 Key'); + } + if (status && status.authMode !== 'chatgpt' && !status.chatGptBackupExists) { + notes.push('未备份官方登录'); + } + if (notes.length === 0 && status && status.authGuarded) { + notes.push('可一键切换登录态'); + } + return `;Codex 登录: ${label}${notes.length ? `(${notes.join(';')})` : ''}`; +} + +async function refreshCodexStatus() { + if (!window.proxyManager.codexStatus) return; + const badgeEl = $('toolBadge-codex-cli'); + const dotEl = $('codexTitleDot'); + try { + const status = await window.proxyManager.codexStatus(); + if (status && status.ok === false && status.error) throw new Error(status.error); + if (!status.exists) { + $('codexStatus').textContent = + `未检测到 Codex CLI 配置文件 (路径: ${status.configPath || ''})`; + $('codexStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '未检测'; + badgeEl.className = 'badge official'; + } + if (dotEl) dotEl.className = 'status-dot'; + return; + } + const auth = formatCodexAuthSummary(status); + if (status.baseUrl) { + const legacy = status.legacyBaseUrl && !status.openaiBaseUrl ? '(旧 base_url 写法)' : ''; + $('codexStatus').textContent = + `已配置本地代理;网关地址: ${status.baseUrl}${legacy}${auth},配置路径: ${status.configPath}`; + $('codexStatus').className = 'notice green'; + if (badgeEl) { + badgeEl.textContent = '已代理'; + badgeEl.className = 'badge active'; + } + if (dotEl) dotEl.className = 'status-dot green'; + } else { + $('codexStatus').textContent = `官方直连${auth};配置路径: ${status.configPath}`; + $('codexStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '直连'; + badgeEl.className = 'badge direct'; + } + if (dotEl) dotEl.className = 'status-dot amber'; + } + } catch (err) { + $('codexStatus').textContent = + `Codex CLI 状态检查失败:${err && err.message ? err.message : err}`; + $('codexStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '失败'; + badgeEl.className = 'badge error'; + } + if (dotEl) dotEl.className = 'status-dot red'; + } +} + +async function refreshCodexDesktopStatus() { + if (!window.proxyManager.codexDesktopStatus) return; + const badgeEl = $('toolBadge-codex-desktop'); + const dotEl = $('codexDesktopTitleDot'); + try { + const status = await window.proxyManager.codexDesktopStatus(); + if (status && status.ok === false && status.error) throw new Error(status.error); + if (!status.exists) { + const auth = formatCodexAuthSummary(status); + $('codexDesktopStatus').textContent = + `未检测到 Codex 用户配置 (路径: ${status.configPath || ''})${auth};应用代理会自动创建共享配置。`; + $('codexDesktopStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '未检测'; + badgeEl.className = 'badge official'; + } + if (dotEl) dotEl.className = 'status-dot'; + return; + } + const auth = formatCodexAuthSummary(status); + if (status.baseUrl) { + const restartText = status.requiresRestart ? ';重启 Codex Desktop 或新开会话后生效' : ''; + const legacy = + status.legacyBaseUrl && !status.openaiBaseUrl ? ';当前为旧 base_url 写法' : ''; + const catalog = + status.managedModelCatalog && status.modelCatalog && status.modelCatalog.exists + ? `;模型目录 ${status.modelCatalog.modelCount || 0} 个模型 / ${status.modelCatalog.routeCount || 0} 条路由` + : ''; + $('codexDesktopStatus').textContent = + `已接入共享 Codex 配置;网关地址: ${status.baseUrl}${legacy}${catalog}${auth},配置路径: ${status.configPath}${restartText}`; + $('codexDesktopStatus').className = 'notice green'; + if (badgeEl) { + badgeEl.textContent = '已代理'; + badgeEl.className = 'badge active'; + } + if (dotEl) dotEl.className = 'status-dot green'; + } else { + const catalog = + status.managedModelCatalog && status.modelCatalog && status.modelCatalog.exists + ? `;已生成模型目录 ${status.modelCatalog.modelCount || 0} 个模型` + : ''; + $('codexDesktopStatus').textContent = + `官方直连${catalog}${auth};配置路径: ${status.configPath}。Codex Desktop 通过共享 config.toml 和启动时模型目录接管。`; + $('codexDesktopStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '直连'; + badgeEl.className = 'badge direct'; + } + if (dotEl) dotEl.className = 'status-dot amber'; + } + } catch (err) { + $('codexDesktopStatus').textContent = + `Codex Desktop 状态检查失败:${err && err.message ? err.message : err}`; + $('codexDesktopStatus').className = 'notice'; + if (badgeEl) { + badgeEl.textContent = '失败'; + badgeEl.className = 'badge error'; + } + if (dotEl) dotEl.className = 'status-dot red'; + } +} + +let toolStatusRefreshInFlight = null; +let lastToolStatusRefresh = 0; + +async function refreshAllToolStatuses(options = {}) { + const now = Date.now(); + if (!options.force && toolStatusRefreshInFlight) return toolStatusRefreshInFlight; + if (options.passive && now - lastToolStatusRefresh < 15000) return; + + const finishPerf = perfStart('refresh.toolStatuses'); + toolStatusRefreshInFlight = Promise.allSettled([ + refreshClaudeDesktopStatus(), + refreshClaudeCodeStatus(), + refreshTraeBridgeStatus(), + refreshAntigravityCliStatus(), + refreshGeminiCliStatus(), + refreshCodexStatus(), + refreshCodexDesktopStatus(), + ]).finally(() => { + lastToolStatusRefresh = Date.now(); + toolStatusRefreshInFlight = null; + finishPerf(); + }); + return toolStatusRefreshInFlight; +} diff --git a/tauri-ui/app.js b/tauri-ui/app.js index 7ff1ce67..56f1e516 100644 --- a/tauri-ui/app.js +++ b/tauri-ui/app.js @@ -3140,1792 +3140,9 @@ async function load() { }); } -function setProxyStatusElement(element, color, text) { - clearElement(element); - const dot = document.createElement('span'); - dot.className = `status-dot ${color}`; - element.append(dot, document.createTextNode(text)); -} - -function isStartupStatusSettling(options = {}) { - if (options.startup === false) return false; - if (config && config.appSettings && config.appSettings.autoStartProxy === false) return false; - return performance.now() - startupStatusStartedAt < STARTUP_STATUS_GRACE_MS; -} - -function renderStartupPortDiagnostics() { - const target = $('heroPortDiagnostics'); - if (target) target.textContent = '端口诊断:等待管理端口启动...'; -} - -function proxyStatusOutputLooksRunning(output) { - const text = String(output || ''); - return ( - /\bRunning\b/i.test(text) || - /\bPID\s*:?\s*\d+\b/i.test(text) || - /Proxy already running/i.test(text) || - /Address:\s*https?:\/\//i.test(text) - ); -} - -function proxyStatusPidFromOutput(output) { - const match = String(output || '').match(/\bPID\s*:?\s*(\d+)\b/i); - return match ? match[1] : ''; -} - -function scheduleStartupProxyRetry() { - if (startupProxyRetryTimer) return; - startupProxyRetryTimer = setTimeout(() => { - startupProxyRetryTimer = null; - if (!isStartupStatusSettling({ startup: true })) return; - lastStatusRefresh = 0; - refreshProxyStatus({ startup: true }).catch((err) => - console.error('startup refreshProxyStatus retry error:', err) - ); - }, STARTUP_STATUS_RETRY_MS); -} - -function deferStartupProxyFailure(statusElement) { - setProxyStatusElement(statusElement, 'amber', '启动中...'); - renderStartupPortDiagnostics(); - scheduleStartupProxyRetry(); -} - -async function refreshProxyStatus(options = {}) { - const now = Date.now(); - const passiveTtl = isMonitorVisible() ? 7000 : 30000; - if (statusRefreshInFlight || (options.passive && now - lastStatusRefresh < passiveTtl)) return; - const finishPerf = perfStart('refresh.proxyStatus'); - statusRefreshInFlight = true; - lastStatusRefresh = now; - const statusElement = $('heroProxyStatus'); - try { - const health = await checkProxyHealth(); - if (health) { - const pid = health.pid ? ` (PID: ${health.pid})` : ''; - setProxyStatusElement(statusElement, 'green', `运行中${pid}`); - renderPortDiagnostics(health); - refreshCertStatusAfterProxyOnline(options); - return; - } - - const result = await window.proxyManager.command('status'); - const output = result.output || ''; - if (proxyStatusOutputLooksRunning(output)) { - const pidValue = proxyStatusPidFromOutput(output); - const pid = pidValue ? ` (PID: ${pidValue})` : ''; - setProxyStatusElement(statusElement, 'green', `运行中${pid}`); - renderPortDiagnostics(null); - refreshCertStatusAfterProxyOnline(options); - return; - } - if (output.trim()) { - if (isStartupStatusSettling(options)) { - deferStartupProxyFailure(statusElement); - return; - } - setProxyStatusElement(statusElement, 'amber', '未运行'); - renderPortDiagnostics(null); - return; - } - if (isStartupStatusSettling(options)) { - deferStartupProxyFailure(statusElement); - return; - } - setProxyStatusElement(statusElement, 'red', '状态未知'); - renderPortDiagnostics(null); - } catch (err) { - // command 调用本身失败时,也用 HTTP 探活兜底 - const health = await checkProxyHealth(); - if (health) { - const pid = health.pid ? ` (PID: ${health.pid})` : ''; - setProxyStatusElement(statusElement, 'green', `运行中${pid}`); - renderPortDiagnostics(health); - refreshCertStatusAfterProxyOnline(options); - return; - } - if (isStartupStatusSettling(options)) { - deferStartupProxyFailure(statusElement); - return; - } - setProxyStatusElement(statusElement, 'red', '检查失败'); - renderPortDiagnostics(null); - } finally { - statusRefreshInFlight = false; - finishPerf(); - } -} - -function renderPortDiagnostics(health) { - const target = $('heroPortDiagnostics'); - if (!target) return; - if (!health || health.appId !== 'https-proxy-manager' || !health.ports) { - target.textContent = '端口诊断:代理未运行或管理端口不可用'; - return; - } - const entries = Array.isArray(health.ports.entries) ? health.ports.entries : []; - const httpsCount = entries.filter((entry) => entry && entry.tls !== false).length; - const httpCount = entries.filter((entry) => entry && entry.tls === false).length; - const entryText = - httpCount > 0 - ? `${httpsCount} 个 HTTPS 入口 + ${httpCount} 个 HTTP 备用入口在线` - : `${httpsCount} 个入口在线`; - const management = health.ports.management ? ` · 管理 ${health.ports.management}` : ''; - target.textContent = `端口诊断:${entryText}${management}`; -} - -async function refreshClaudeDesktopStatus() { - if (!window.proxyManager.claudeDesktopStatus) return; - const badgeEl = $('toolBadge-claude-desktop'); - const dotEl = $('claudeDesktopTitleDot'); - try { - const status = await window.proxyManager.claudeDesktopStatus(); - if (!status.supported) { - $('claudeDesktopStatus').textContent = - `当前平台不可写入 Claude Desktop 配置:${status.error || ''}`; - if (badgeEl) { - badgeEl.textContent = '不支持'; - badgeEl.className = 'badge error'; - } - if (dotEl) dotEl.className = 'status-dot red'; - return; - } - const configured = status.configured ? '已写入 3P profile' : '未写入'; - const actual = status.actualBaseUrl || status.gatewayUrl || '无'; - const expected = status.expectedBaseUrl || '无'; - const desiredMode = status.desiredMode ? `;选择模式:${status.desiredMode}` : ''; - const matchText = status.matchesExpected === false ? ';状态:需重新写入' : ''; - const pathText = - status.profilePath || status.configPath - ? `\nProfile:${status.profilePath || status.configPath}` - : ''; - const evidence = status.clientEvidence || {}; - const activeCode = evidence.activeCodeProcess || {}; - const latestSession = evidence.latestCodeSession || {}; - const clientText = evidence.processCount - ? `\n客户端:Claude PID ${evidence.mainWindowPid || '未知'};Code 模型:${activeCode.model || latestSession.model || '未检测'};CLI Session:${activeCode.cliSessionId || latestSession.cliSessionId || '未检测'}` - : ''; - $('claudeDesktopStatus').textContent = - `${configured};模式:${status.mode}${desiredMode};当前 URL:${actual};预期 URL:${expected}${matchText}${pathText}${clientText}`; - - // Update badge and title dot - if (badgeEl) { - if (status.configured) { - if (status.mode === 'proxy') { - badgeEl.textContent = '已代理'; - badgeEl.className = 'badge active'; - } else if (status.mode === 'direct') { - badgeEl.textContent = '直连'; - badgeEl.className = 'badge direct'; - } else { - badgeEl.textContent = '官方'; - badgeEl.className = 'badge official'; - } - } else { - badgeEl.textContent = '未接管'; - badgeEl.className = 'badge official'; - } - } - if (dotEl) { - if (status.configured && status.mode === 'proxy') { - dotEl.className = 'status-dot green'; - } else if (status.configured && status.mode === 'direct') { - dotEl.className = 'status-dot amber'; - } else { - dotEl.className = 'status-dot'; - } - } - } catch (err) { - $('claudeDesktopStatus').textContent = - `Claude Desktop 状态检查失败:${err && err.message ? err.message : err}`; - if (badgeEl) { - badgeEl.textContent = '失败'; - badgeEl.className = 'badge error'; - } - if (dotEl) dotEl.className = 'status-dot red'; - } -} - -async function refreshClaudeCodeStatus() { - if (!window.proxyManager.claudeCodeStatus) return; - const badgeEl = $('toolBadge-claude-code'); - const dotEl = $('claudeCodeTitleDot'); - try { - const status = await window.proxyManager.claudeCodeStatus(); - if (!status.exists) { - $('claudeCodeStatus').textContent = - `未检测到 Claude Code 配置文件 (路径: ${status.configPath || ''})`; - $('claudeCodeStatus').className = 'notice'; - if (badgeEl) { - badgeEl.textContent = '未检测'; - badgeEl.className = 'badge official'; - } - if (dotEl) dotEl.className = 'status-dot'; - return; - } - if (status.baseUrl) { - $('claudeCodeStatus').textContent = - `已配置本地代理;网关地址: ${status.baseUrl},配置路径: ${status.configPath}`; - $('claudeCodeStatus').className = 'notice green'; - if (badgeEl) { - badgeEl.textContent = '已代理'; - badgeEl.className = 'badge active'; - } - if (dotEl) dotEl.className = 'status-dot green'; - } else { - $('claudeCodeStatus').textContent = `官方直连/已启用登录;配置路径: ${status.configPath}`; - $('claudeCodeStatus').className = 'notice'; - if (badgeEl) { - badgeEl.textContent = '直连'; - badgeEl.className = 'badge direct'; - } - if (dotEl) dotEl.className = 'status-dot amber'; - } - } catch (err) { - $('claudeCodeStatus').textContent = - `Claude Code 状态检查失败:${err && err.message ? err.message : err}`; - $('claudeCodeStatus').className = 'notice'; - if (badgeEl) { - badgeEl.textContent = '失败'; - badgeEl.className = 'badge error'; - } - if (dotEl) dotEl.className = 'status-dot red'; - } -} - -function formatTraeBridgeUrl(status) { - const url = status && (status.bridgeUrl || status.url || status.healthUrl); - if (!url) return 'http://127.0.0.1:17861'; - return String(url).replace(/\/health$/, ''); -} - -function formatTraeCommand(status, key, fallback = '未发现') { - const value = status && status[key]; - return value ? String(value) : fallback; -} - -async function refreshTraeBridgeStatus() { - const badgeEl = $('toolBadge-trae-bridge'); - const dotEl = $('traeBridgeTitleDot'); - const statusEl = $('traeBridgeStatus'); - if (!statusEl) return; - if (!window.proxyManager.traeBridgeStatus) { - statusEl.textContent = '当前版本缺少 Trae Bridge 状态接口。'; - statusEl.className = 'notice'; - if (badgeEl) { - badgeEl.textContent = '不可用'; - badgeEl.className = 'badge error'; - } - if (dotEl) dotEl.className = 'status-dot red'; - return; - } - try { - const status = await window.proxyManager.traeBridgeStatus(); - const reachable = - status && - status.ok !== false && - (status.statusCode === 200 || - Boolean(status.command) || - Boolean(status.waitCommand) || - Boolean(status.streamCommand) || - Boolean(status.rawStreamCommand)); - if (!reachable) { - const reason = status && (status.error || status.message || status.statusCode); - statusEl.textContent = `Trae Bridge 未连接;本地桥接服务通常位于 http://127.0.0.1:17861。${reason ? `原因:${reason}` : '请确认 Trae 已启动并加载扩展。'}`; - statusEl.className = 'notice'; - if (badgeEl) { - badgeEl.textContent = '未连接'; - badgeEl.className = 'badge official'; - } - if (dotEl) dotEl.className = 'status-dot amber'; - return; - } - - const bridgeUrl = formatTraeBridgeUrl(status); - const waitCommand = formatTraeCommand( - status, - 'waitCommand', - formatTraeCommand(status, 'command') - ); - const streamCommand = formatTraeCommand(status, 'streamCommand'); - const rawStreamCommand = formatTraeCommand(status, 'rawStreamCommand'); - const discoveredCount = Number.isFinite(status.discoveredCommandCount) - ? status.discoveredCommandCount - : Array.isArray(status.discoveredCommands) - ? status.discoveredCommands.length - : 0; - const rawAvailable = rawStreamCommand !== '未发现'; - const rawText = rawAvailable ? `Raw 调试流:${rawStreamCommand}` : 'Raw 调试流:未发现'; - - statusEl.textContent = `Trae Bridge 已连接;地址:${bridgeUrl};等待命令:${waitCommand};流式命令:${streamCommand};${rawText};发现命令:${discoveredCount} 个。`; - statusEl.className = 'notice green'; - if (badgeEl) { - badgeEl.textContent = rawAvailable ? '已连接' : '可用'; - badgeEl.className = 'badge active'; - } - if (dotEl) dotEl.className = 'status-dot green'; - refreshTraeDebugSettings(); - } catch (err) { - statusEl.textContent = `Trae Bridge 状态检查失败:${err && err.message ? err.message : err}`; - statusEl.className = 'notice'; - if (badgeEl) { - badgeEl.textContent = '失败'; - badgeEl.className = 'badge error'; - } - if (dotEl) dotEl.className = 'status-dot red'; - } -} - -let lastTraeNativeDebugCommands = [ - 'python scripts\\trae-frida-scan.py', - 'python scripts\\trae-frida-native-probe.py --pid --duration 45', - 'python scripts\\trae-frida-native-probe.py --pid --duration 45', -].join('\n'); - -function renderTraeDebugEvents(events) { - const target = $('traeDebugEvents'); - if (!target) return; - clearElement(target); - if (!Array.isArray(events) || events.length === 0) { - const empty = document.createElement('div'); - empty.className = 'muted'; - empty.textContent = '暂无过程事件。先发起一次 Trae 请求,或启用 Raw Stream Patch 后再刷新。'; - target.appendChild(empty); - return; - } - events - .slice(-40) - .reverse() - .forEach((event) => { - const source = event.source || 'trae'; - const kind = event.kind || event.event || 'event'; - const message = - event.message || - [ - kind, - event.toolName ? `tool=${event.toolName}` : '', - event.toolStatus ? `status=${event.toolStatus}` : '', - event.bodyLen == null ? '' : `body=${event.bodyLen}`, - ] - .filter(Boolean) - .join(' '); - const item = document.createElement('div'); - item.className = 'trae-debug-event'; - const sourceEl = document.createElement('div'); - sourceEl.className = 'trae-debug-event-source'; - sourceEl.textContent = source; - const messageEl = document.createElement('div'); - messageEl.className = 'trae-debug-event-message'; - messageEl.textContent = message; - item.append(sourceEl, messageEl); - target.appendChild(item); - }); -} - -function formatTraeNativeCommandBlock(status, scanSummary) { - const commands = status && status.commands ? status.commands : {}; - const aiPid = scanSummary && scanSummary.aiAgentPid; - const ahaPid = scanSummary && scanSummary.ahaNetPid; - return [ - commands.installFrida || 'python -m pip install --user frida-tools', - commands.scan || 'python scripts\\trae-frida-scan.py', - aiPid - ? `python scripts\\trae-frida-native-probe.py --pid ${aiPid} --duration 45` - : commands.aiAgentHook || - 'python scripts\\trae-frida-native-probe.py --pid --duration 45', - ahaPid - ? `python scripts\\trae-frida-native-probe.py --pid ${ahaPid} --duration 45` - : commands.ahaNetHook || - 'python scripts\\trae-frida-native-probe.py --pid --duration 45', - ].join('\n'); -} - -async function refreshTraeDebugEvents() { - const summaryEl = $('traeDebugSummary'); - if (!summaryEl || !window.proxyManager.traeDebugEvents) return; - summaryEl.textContent = '正在读取 Trae 过程事件...'; - summaryEl.className = 'notice'; - try { - const [eventsResult, nativeStatus] = await Promise.all([ - window.proxyManager.traeDebugEvents(), - window.proxyManager.traeDebugNativeStatus - ? window.proxyManager.traeDebugNativeStatus() - : Promise.resolve(null), - ]); - if (nativeStatus && nativeStatus.commands) { - lastTraeNativeDebugCommands = formatTraeNativeCommandBlock(nativeStatus); - } - if (!eventsResult || eventsResult.ok === false) { - summaryEl.textContent = `Trae Debug 事件读取失败:${eventsResult && eventsResult.error ? eventsResult.error : '未知错误'}`; - summaryEl.className = 'notice'; - renderTraeDebugEvents([]); - return; - } - const rawCount = - eventsResult.raw && Number.isFinite(eventsResult.raw.rawEventCount) - ? eventsResult.raw.rawEventCount - : 0; - const logCount = Number.isFinite(eventsResult.logEventCount) ? eventsResult.logEventCount : 0; - const eventCount = Array.isArray(eventsResult.events) ? eventsResult.events.length : 0; - summaryEl.textContent = `已聚合 ${eventCount} 条安全过程事件;Raw ${rawCount} 条,日志摘要 ${logCount} 条。`; - summaryEl.className = 'notice green'; - renderTraeDebugEvents(eventsResult.events || []); - } catch (err) { - summaryEl.textContent = `Trae Debug 事件读取失败:${err && err.message ? err.message : err}`; - summaryEl.className = 'notice'; - } -} - -function applyTraeDebugSettingsToUi(settings) { - const rawEl = $('traeRawCommentsEnabled'); - const visibleEl = $('traeApiVisibleProgressEnabled'); - const claudeProcessStyleEl = $('traeClaudeProcessStyle'); - const logEl = $('traeLogProgressEnabled'); - if (rawEl) rawEl.checked = Boolean(settings && settings.rawCommentsEnabled); - if (visibleEl) visibleEl.checked = Boolean(settings && settings.apiVisibleProgressEnabled); - if (claudeProcessStyleEl) - claudeProcessStyleEl.value = - settings && settings.claudeProcessProgressEnabled - ? settings.claudeProcessStyle || 'thinking' - : ''; - if (logEl) logEl.checked = settings ? settings.liveLogProgress !== false : true; -} - -function collectTraeDebugSettingsFromUi() { - const visible = Boolean( - $('traeApiVisibleProgressEnabled') && $('traeApiVisibleProgressEnabled').checked - ); - return { - rawCommentsEnabled: - visible || Boolean($('traeRawCommentsEnabled') && $('traeRawCommentsEnabled').checked), - apiVisibleProgressEnabled: visible, - claudeProcessStyle: - $('traeClaudeProcessStyle') && $('traeClaudeProcessStyle').value - ? $('traeClaudeProcessStyle').value - : '', - liveLogProgress: !( - $('traeLogProgressEnabled') && $('traeLogProgressEnabled').checked === false - ), - }; -} - -async function refreshTraeDebugSettings() { - if (!window.proxyManager.traeDebugSettings) return; - try { - const settings = await window.proxyManager.traeDebugSettings(); - if (settings && settings.ok !== false) applyTraeDebugSettingsToUi(settings); - } catch (err) { - // Debug switches are optional; the status banner will show bridge health separately. - } -} - -async function saveTraeDebugSettingsFromUi() { - const summaryEl = $('traeDebugSummary'); - if (!window.proxyManager.saveTraeDebugSettings) return; - const payload = collectTraeDebugSettingsFromUi(); - if ($('traeRawCommentsEnabled')) $('traeRawCommentsEnabled').checked = payload.rawCommentsEnabled; - if (summaryEl) { - summaryEl.textContent = '正在保存 Trae Debug 开关...'; - summaryEl.className = 'notice'; - } - try { - const settings = await window.proxyManager.saveTraeDebugSettings(payload); - if (!settings || settings.ok === false) - throw new Error((settings && settings.error) || '保存失败'); - applyTraeDebugSettingsToUi(settings); - const msg = settings.claudeProcessProgressEnabled - ? settings.claudeProcessStyle === 'text' - ? 'Trae Claude 文本备用过程块已开启;后续 Claude 流式 API 会把 Trae 过程写成独立文本块。' - : 'Trae Claude Thinking 过程块已开启;后续 Claude 流式 API 会尝试把 Trae 过程写入 thinking 区域。' - : settings.apiVisibleProgressEnabled - ? 'Trae API 可见过程已开启;后续流式 API 会把安全过程摘要写入 assistant 正文。' - : 'Trae Debug 开关已保存;API 正文不再注入可见过程摘要。'; - if (summaryEl) { - summaryEl.textContent = msg; - summaryEl.className = - settings.apiVisibleProgressEnabled || settings.claudeProcessProgressEnabled - ? 'notice green' - : 'notice'; - } - $('output').textContent = msg; - showToast(msg, 'success'); - } catch (err) { - const msg = `保存 Trae Debug 开关失败:${err && err.message ? err.message : err}`; - if (summaryEl) summaryEl.textContent = msg; - showToast(msg, 'error'); - } -} - -function formatTraeClaudeProcessSelfTest(result) { - if (!result || result.ok === false) { - return `Claude 过程输出自检失败:${result && result.error ? result.error : '未知错误'}`; - } - const summary = result.summary || {}; - const lines = [ - `pass=${Boolean(result.pass)} status=${result.statusCode || ''} source=${result.source || ''}`, - `model=${(result.request && result.request.model) || ''} expectedStyle=${result.expectedStyle || ''}`, - `debugMode=${(result.settings && result.settings.rawProgressMode) || ''} debugStyle=${(result.settings && result.settings.claudeProcessStyle) || ''}`, - `endpoint=${result.endpoint || ''}`, - `thinking_delta=${summary.thinkingDeltaCount || 0} signature_delta=${summary.signatureDeltaCount || 0} text_delta=${summary.textDeltaCount || 0}`, - `hasTraeProcess=${Boolean(summary.hasTraeProcessText)} planItem=${Boolean(summary.hasTraePlanItem)} timing=${Boolean(summary.hasTraeTiming)} messageStop=${Boolean(summary.hasDone)}`, - result.hint || '', - ]; - return lines.filter(Boolean).join('\n'); -} - -async function runClaudeDesktopProcessSelfTest({ - summaryEl, - outputEl, - runningText, - successText, -} = {}) { - if (!window.proxyManager.traeDebugClaudeProcessSelfTest) return; - if (summaryEl) { - summaryEl.textContent = - runningText || - '正在验证 Claude Desktop 过程输出;会通过 Desktop Gateway 发起一条短 Trae 流式请求...'; - summaryEl.className = 'notice'; - } - if (outputEl) outputEl.textContent = 'Claude 过程输出自检运行中...'; - try { - const result = await window.proxyManager.traeDebugClaudeProcessSelfTest({ - useClaudeDesktopGateway: true, - prompt: '请只回复 OK。', - timeoutMs: 90000, - }); - const text = formatTraeClaudeProcessSelfTest(result); - if (outputEl) outputEl.textContent = text; - if (summaryEl) { - summaryEl.textContent = - result && result.pass - ? successText || - 'Claude Desktop 过程输出自检通过;Desktop Gateway SSE 已包含 Trae 过程事件。' - : text; - summaryEl.className = result && result.pass ? 'notice green' : 'notice'; - } - showToast( - result && result.pass ? 'Claude 过程输出自检通过。' : 'Claude 过程输出自检未通过。', - result && result.pass ? 'success' : 'error' - ); - } catch (err) { - const msg = `Claude 过程输出自检失败:${err && err.message ? err.message : err}`; - if (summaryEl) summaryEl.textContent = msg; - if (outputEl) outputEl.textContent = msg; - showToast(msg, 'error'); - } -} - -async function testTraeClaudeProcessOutput() { - await runClaudeDesktopProcessSelfTest({ - summaryEl: $('traeDebugSummary'), - outputEl: $('traeNativeDeepDebugOutput') || $('traeNativeDebugOutput'), - }); -} - -async function testClaudeDesktopProcessOutput() { - await runClaudeDesktopProcessSelfTest({ - summaryEl: $('claudeDesktopStatus'), - outputEl: $('output'), - runningText: - '正在验证 Claude Desktop 过程显示;会通过当前 Desktop Gateway 跑一条短 Trae 流式请求...', - successText: '过程显示自检通过;Claude Desktop Gateway SSE 已包含 Trae 过程文本。', - }); -} - -function firstClaudeDesktopTraeRouteId() { - ensureClaudeDesktop(); - for (const model of config.claudeDesktop.models || []) { - const resolved = resolveClaudeDesktopModel(model); - const profile = config.profiles.find((item) => item.id === resolved.profileId) || {}; - const target = String(profile.targetUrl || '').toLowerCase(); - if ( - profile.protocol === 'trae' || - resolved.profileId === 'trae-official' || - target.includes('coresg-normal.trae.ai') - ) { - return resolved.routeId; - } - } - const first = config.claudeDesktop.models && config.claudeDesktop.models[0]; - return first ? routeIdOfClaudeDesktopModel(first) : ''; -} - -async function copyClaudeDesktopProcessPrompt() { - let copied = false; - try { - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(CLAUDE_DESKTOP_PROCESS_VERIFICATION_PROMPT); - copied = true; - } - } catch (err) { - copied = false; - } - const routeId = firstClaudeDesktopTraeRouteId(); - const msg = [ - copied ? 'Claude Desktop 验证提示已复制。' : 'Claude Desktop 验证提示如下。', - routeId ? `在 Claude Desktop 里选择模型 ${routeId},粘贴后发送。` : '', - '如果窗口里出现 [Trae plan_item]、[Trae timing] 或类似过程文本,就说明客户端可见链路已打通。', - '', - CLAUDE_DESKTOP_PROCESS_VERIFICATION_PROMPT, - ] - .filter(Boolean) - .join('\n'); - $('output').textContent = msg; - showDiagnosticsView('outputPanel'); - showToast(copied ? 'Claude Desktop 验证提示已复制' : '已显示 Claude Desktop 验证提示', 'success'); -} - -async function scanTraeNativeDebug() { - const outputEl = $('traeNativeDebugOutput'); - const summaryEl = $('traeDebugSummary'); - if (!outputEl || !window.proxyManager.traeDebugNativeScan) return; - outputEl.textContent = '正在扫描 Trae Native PID...'; - if (summaryEl) { - summaryEl.textContent = 'Native 扫描运行中;这是一次性 Frida attach 扫描。'; - summaryEl.className = 'notice'; - } - try { - const [status, scan] = await Promise.all([ - window.proxyManager.traeDebugNativeStatus - ? window.proxyManager.traeDebugNativeStatus() - : Promise.resolve(null), - window.proxyManager.traeDebugNativeScan(), - ]); - const scanSummary = scan && scan.summary ? scan.summary : {}; - if (status && status.commands) { - lastTraeNativeDebugCommands = formatTraeNativeCommandBlock(status, scanSummary); - } - if (scan && scan.running) { - outputEl.textContent = [ - 'Native 扫描已启动,后台运行中。', - scan.summary && scan.summary.ahaNetPid - ? `已缓存 aha_net PID: ${scan.summary.ahaNetPid}` - : '等待扫描结果...', - '', - scan.output || '', - ].join('\n'); - if (summaryEl) { - summaryEl.textContent = 'Native PID 扫描后台运行中;稍后会自动刷新。'; - summaryEl.className = 'notice'; - } - setTimeout(scanTraeNativeDebug, 13000); - return; - } - const lines = [ - scan && scan.ok - ? 'Native 扫描完成。' - : `Native 扫描失败:${(scan && (scan.error || scan.status)) || '未知错误'}`, - `ai_agent PID: ${scanSummary.aiAgentPid || '未发现'}`, - `aha_net PID: ${scanSummary.ahaNetPid || '未发现'}`, - '', - '候选进程:', - ...(Array.isArray(scanSummary.candidates) && scanSummary.candidates.length - ? scanSummary.candidates.map((item) => `- ${item.pid}: ${item.modules.join(', ')}`) - : ['- 未发现 native 候选']), - '', - '脱敏扫描输出:', - scan && scan.output ? scan.output : '', - ]; - outputEl.textContent = lines.join('\n'); - if (summaryEl) { - summaryEl.textContent = - scan && scan.ok - ? 'Native PID 扫描完成;可复制 Hook 命令进行短时本机 Debug。' - : 'Native PID 扫描未完成;可检查 Frida/Python 状态。'; - summaryEl.className = scan && scan.ok ? 'notice green' : 'notice'; - } - } catch (err) { - outputEl.textContent = `Native 扫描失败:${err && err.message ? err.message : err}`; - if (summaryEl) summaryEl.textContent = outputEl.textContent; - } -} - -function formatTraeNativeDeepOutput(result) { - if (!result || result.ok === false) { - return `Native 深入事件读取失败:${result && result.error ? result.error : '未知错误'}`; - } - const counts = result.counts && typeof result.counts === 'object' ? result.counts : {}; - const countLines = Object.keys(counts) - .sort() - .map((key) => `- ${key}: ${counts[key]}`); - const events = Array.isArray(result.events) ? result.events : []; - const eventLines = events.slice(-36).map((event) => { - const keys = - Array.isArray(event.keys) && event.keys.length ? ` keys=${event.keys.join(',')}` : ''; - const sse = - Array.isArray(event.sseEventNames) && event.sseEventNames.length - ? ` sse=${event.sseEventNames.join(',')}` - : ''; - const claude = - Array.isArray(event.sseSemantics) && event.sseSemantics.length - ? ` claude=${event.sseSemantics - .map((item) => item.claudeCandidate) - .filter(Boolean) - .filter((item, index, arr) => arr.indexOf(item) === index) - .join(',')}` - : ''; - const signals = - event.contains && typeof event.contains === 'object' - ? Object.keys(event.contains) - .filter((key) => event.contains[key]) - .join(',') - : ''; - return [ - `- ${event.kind || event.event || 'event'}`, - event.urlPath ? ` url=${event.urlPath}` : '', - event.bytesRead == null ? '' : ` bytes=${event.bytesRead}`, - sse, - claude, - signals ? ` signals=${signals}` : '', - keys, - ] - .filter(Boolean) - .join(''); - }); - return [ - `状态: ${result.status || 'unknown'}${result.running ? ' (running)' : ''}`, - `PID: ${result.pid || '未选择'},模式: ${result.mode || '未启动'},事件: ${result.eventCount || 0}`, - result.error ? `错误: ${result.error}` : '', - result.warning ? `提示: ${result.warning}` : '', - '', - '计数:', - ...(countLines.length ? countLines : ['- 暂无']), - '', - '最近事件:', - ...(eventLines.length ? eventLines : ['- 暂无;启动后发起一次 Trae API 请求再刷新。']), - ] - .filter((line) => line !== '') - .join('\n'); -} - -function waitMs(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function getTraeAhaPidFromScan(scan) { - if (scan && scan.summary && scan.summary.ahaNetPid) return Number(scan.summary.ahaNetPid); - const candidates = - scan && scan.summary && Array.isArray(scan.summary.candidates) ? scan.summary.candidates : []; - const candidate = candidates.find( - (item) => Array.isArray(item.modules) && item.modules.includes('aha_net.dll') - ); - return candidate ? Number(candidate.pid) : 0; -} - -async function ensureTraeNativePidReady(outputEl, summaryEl) { - if (!window.proxyManager.traeDebugNativeScan) { - throw new Error('Trae Native PID 扫描接口不可用'); - } - let lastScan = null; - for (let attempt = 0; attempt < 16; attempt += 1) { - lastScan = await window.proxyManager.traeDebugNativeScan(); - const pid = getTraeAhaPidFromScan(lastScan); - if (pid) return { pid, scan: lastScan }; - const running = lastScan && lastScan.running; - const line = running - ? `正在自动扫描 Native PID... ${attempt + 1}/16` - : `正在准备 Native PID... ${attempt + 1}/16`; - if (outputEl) { - outputEl.textContent = [ - line, - lastScan && lastScan.output ? lastScan.output : '等待扫描结果...', - ].join('\n\n'); - } - if (summaryEl) { - summaryEl.textContent = line; - summaryEl.className = 'notice'; - } - await waitMs(attempt < 2 ? 1800 : 2500); - } - const error = lastScan && (lastScan.error || lastScan.status); - throw new Error( - error ? `Native PID 扫描未完成:${error}` : '未发现 Trae aha_net PID,请确认 Trae 正在运行' - ); -} - -async function refreshTraeNativeDeepDebug() { - const outputEl = $('traeNativeDeepDebugOutput'); - const summaryEl = $('traeDebugSummary'); - if (!outputEl || !window.proxyManager.traeDebugNativeDeepEvents) return null; - try { - const result = await window.proxyManager.traeDebugNativeDeepEvents(); - outputEl.textContent = formatTraeNativeDeepOutput(result); - if (summaryEl && result && result.ok !== false) { - summaryEl.textContent = result.running - ? `Native 深入探针运行中;已捕获 ${result.eventCount || 0} 条脱敏结构事件。` - : `Native 深入探针状态:${result.status || 'idle'};已捕获 ${result.eventCount || 0} 条脱敏结构事件。`; - summaryEl.className = result.eventCount ? 'notice green' : 'notice'; - } - return result; - } catch (err) { - outputEl.textContent = `Native 深入事件读取失败:${err && err.message ? err.message : err}`; - if (summaryEl) summaryEl.textContent = outputEl.textContent; - return null; - } -} - -async function startTraeNativeDeepDebug() { - const outputEl = $('traeNativeDeepDebugOutput'); - const summaryEl = $('traeDebugSummary'); - if (!window.proxyManager.startTraeDebugNativeDeep) return; - if (outputEl) outputEl.textContent = '正在一键启用 Native 深入探针...'; - if (summaryEl) { - summaryEl.textContent = - '正在自动扫描 PID 并启动深入探针;这是短时 Frida attach,只用于本机诊断。'; - summaryEl.className = 'notice'; - } - try { - const ready = await ensureTraeNativePidReady(outputEl, summaryEl); - if (outputEl) outputEl.textContent = `已找到 aha_net PID ${ready.pid},正在启动深入探针...`; - const result = await window.proxyManager.startTraeDebugNativeDeep({ - pid: ready.pid, - durationSeconds: 60, - maxPreview: 360, - }); - if (!result || result.ok === false) { - const msg = `Native 深入探针启动失败:${result && result.error ? result.error : '未知错误'}`; - if (outputEl) outputEl.textContent = msg; - if (summaryEl) summaryEl.textContent = msg; - showToast(msg, 'error'); - return; - } - if (outputEl) outputEl.textContent = formatTraeNativeDeepOutput(result); - const msg = 'Native 深入探针已启动;现在发起一次 Trae API 请求,然后刷新深入事件。'; - if (summaryEl) { - summaryEl.textContent = msg; - summaryEl.className = 'notice green'; - } - showToast(msg, 'success'); - setTimeout(refreshTraeNativeDeepDebug, 2500); - } catch (err) { - const msg = `Native 深入探针启动失败:${err && err.message ? err.message : err}`; - if (outputEl) outputEl.textContent = msg; - if (summaryEl) summaryEl.textContent = msg; - showToast(msg, 'error'); - } -} - -async function stopTraeNativeDeepDebug() { - if (!window.proxyManager.stopTraeDebugNativeDeep) return; - try { - const result = await window.proxyManager.stopTraeDebugNativeDeep(); - const outputEl = $('traeNativeDeepDebugOutput'); - if (outputEl) outputEl.textContent = formatTraeNativeDeepOutput(result); - showToast('Native 深入探针停止请求已发送。', 'success'); - } catch (err) { - showToast(`Native 深入探针停止失败:${err && err.message ? err.message : err}`, 'error'); - } -} - -async function refreshAntigravityCliStatus() { - if (!window.proxyManager.antigravityCliStatus) return; - const badgeEl = $('toolBadge-antigravity-cli'); - const dotEl = $('antigravityCliTitleDot'); - try { - const status = await window.proxyManager.antigravityCliStatus(); - if (!status.exists) { - $('antigravityCliStatus').textContent = - `未检测到 Antigravity CLI 配置文件 (路径: ${status.configPath || ''})`; - $('antigravityCliStatus').className = 'notice'; - if (badgeEl) { - badgeEl.textContent = '未检测'; - badgeEl.className = 'badge official'; - } - if (dotEl) dotEl.className = 'status-dot'; - return; - } - if (status.baseUrl) { - $('antigravityCliStatus').textContent = - `已配置本地代理;网关地址: ${status.baseUrl},配置路径: ${status.configPath}`; - $('antigravityCliStatus').className = 'notice green'; - if (badgeEl) { - badgeEl.textContent = '已代理'; - badgeEl.className = 'badge active'; - } - if (dotEl) dotEl.className = 'status-dot green'; - } else { - $('antigravityCliStatus').textContent = `官方直连/未配置;配置路径: ${status.configPath}`; - $('antigravityCliStatus').className = 'notice'; - if (badgeEl) { - badgeEl.textContent = '直连'; - badgeEl.className = 'badge direct'; - } - if (dotEl) dotEl.className = 'status-dot amber'; - } - } catch (err) { - $('antigravityCliStatus').textContent = - `Antigravity CLI 状态检查失败:${err && err.message ? err.message : err}`; - $('antigravityCliStatus').className = 'notice'; - if (badgeEl) { - badgeEl.textContent = '失败'; - badgeEl.className = 'badge error'; - } - if (dotEl) dotEl.className = 'status-dot red'; - } -} - -async function refreshGeminiCliStatus() { - if (!window.proxyManager.geminiCliStatus) return; - const badgeEl = $('toolBadge-gemini-cli'); - const dotEl = $('geminiCliTitleDot'); - try { - const status = await window.proxyManager.geminiCliStatus(); - if (!status.exists) { - $('geminiCliStatus').textContent = - `未检测到 Google Gemini CLI 配置文件 (路径: ${status.configPath || ''})`; - $('geminiCliStatus').className = 'notice'; - if (badgeEl) { - badgeEl.textContent = '未检测'; - badgeEl.className = 'badge official'; - } - if (dotEl) dotEl.className = 'status-dot'; - return; - } - if (status.apiEndpoint || status.envBaseUrl) { - const ep = status.apiEndpoint || status.envBaseUrl; - $('geminiCliStatus').textContent = - `已配置本地代理;网关地址: ${ep},配置路径: ${status.configPath}`; - $('geminiCliStatus').className = 'notice green'; - if (badgeEl) { - badgeEl.textContent = '已代理'; - badgeEl.className = 'badge active'; - } - if (dotEl) dotEl.className = 'status-dot green'; - } else { - $('geminiCliStatus').textContent = `官方直连/已启用认证;配置路径: ${status.configPath}`; - $('geminiCliStatus').className = 'notice'; - if (badgeEl) { - badgeEl.textContent = '直连'; - badgeEl.className = 'badge direct'; - } - if (dotEl) dotEl.className = 'status-dot amber'; - } - } catch (err) { - $('geminiCliStatus').textContent = - `Google Gemini CLI 状态检查失败:${err && err.message ? err.message : err}`; - $('geminiCliStatus').className = 'notice'; - if (badgeEl) { - badgeEl.textContent = '失败'; - badgeEl.className = 'badge error'; - } - if (dotEl) dotEl.className = 'status-dot red'; - } -} - -function formatCodexAuthSummary(status) { - const labels = { - chatgpt: '官方登录', - api: 'API 登录', - mixed: '官方 token + 用户 API Key', - 'legacy-managed-api-key': '旧版代理占位 Key', - none: '未登录', - }; - const label = labels[status && status.authMode] || '未知'; - const notes = []; - if (status && status.authConflict) { - notes.push('Codex 运行时仍会二选一,建议切换前清理不需要的凭据'); - } - if (status && status.hasManagedApiKey) { - notes.push('检测到旧版代理占位 Key,恢复直连会自动清理'); - } - if (status && status.hasProxyManagedApiKey) { - notes.push('本地代理占位 Key'); - } - if (status && status.authMode !== 'chatgpt' && !status.chatGptBackupExists) { - notes.push('未备份官方登录'); - } - if (notes.length === 0 && status && status.authGuarded) { - notes.push('可一键切换登录态'); - } - return `;Codex 登录: ${label}${notes.length ? `(${notes.join(';')})` : ''}`; -} - -async function refreshCodexStatus() { - if (!window.proxyManager.codexStatus) return; - const badgeEl = $('toolBadge-codex-cli'); - const dotEl = $('codexTitleDot'); - try { - const status = await window.proxyManager.codexStatus(); - if (status && status.ok === false && status.error) throw new Error(status.error); - if (!status.exists) { - $('codexStatus').textContent = - `未检测到 Codex CLI 配置文件 (路径: ${status.configPath || ''})`; - $('codexStatus').className = 'notice'; - if (badgeEl) { - badgeEl.textContent = '未检测'; - badgeEl.className = 'badge official'; - } - if (dotEl) dotEl.className = 'status-dot'; - return; - } - const auth = formatCodexAuthSummary(status); - if (status.baseUrl) { - const legacy = status.legacyBaseUrl && !status.openaiBaseUrl ? '(旧 base_url 写法)' : ''; - $('codexStatus').textContent = - `已配置本地代理;网关地址: ${status.baseUrl}${legacy}${auth},配置路径: ${status.configPath}`; - $('codexStatus').className = 'notice green'; - if (badgeEl) { - badgeEl.textContent = '已代理'; - badgeEl.className = 'badge active'; - } - if (dotEl) dotEl.className = 'status-dot green'; - } else { - $('codexStatus').textContent = `官方直连${auth};配置路径: ${status.configPath}`; - $('codexStatus').className = 'notice'; - if (badgeEl) { - badgeEl.textContent = '直连'; - badgeEl.className = 'badge direct'; - } - if (dotEl) dotEl.className = 'status-dot amber'; - } - } catch (err) { - $('codexStatus').textContent = - `Codex CLI 状态检查失败:${err && err.message ? err.message : err}`; - $('codexStatus').className = 'notice'; - if (badgeEl) { - badgeEl.textContent = '失败'; - badgeEl.className = 'badge error'; - } - if (dotEl) dotEl.className = 'status-dot red'; - } -} - -async function refreshCodexDesktopStatus() { - if (!window.proxyManager.codexDesktopStatus) return; - const badgeEl = $('toolBadge-codex-desktop'); - const dotEl = $('codexDesktopTitleDot'); - try { - const status = await window.proxyManager.codexDesktopStatus(); - if (status && status.ok === false && status.error) throw new Error(status.error); - if (!status.exists) { - const auth = formatCodexAuthSummary(status); - $('codexDesktopStatus').textContent = - `未检测到 Codex 用户配置 (路径: ${status.configPath || ''})${auth};应用代理会自动创建共享配置。`; - $('codexDesktopStatus').className = 'notice'; - if (badgeEl) { - badgeEl.textContent = '未检测'; - badgeEl.className = 'badge official'; - } - if (dotEl) dotEl.className = 'status-dot'; - return; - } - const auth = formatCodexAuthSummary(status); - if (status.baseUrl) { - const restartText = status.requiresRestart ? ';重启 Codex Desktop 或新开会话后生效' : ''; - const legacy = - status.legacyBaseUrl && !status.openaiBaseUrl ? ';当前为旧 base_url 写法' : ''; - const catalog = - status.managedModelCatalog && status.modelCatalog && status.modelCatalog.exists - ? `;模型目录 ${status.modelCatalog.modelCount || 0} 个模型 / ${status.modelCatalog.routeCount || 0} 条路由` - : ''; - $('codexDesktopStatus').textContent = - `已接入共享 Codex 配置;网关地址: ${status.baseUrl}${legacy}${catalog}${auth},配置路径: ${status.configPath}${restartText}`; - $('codexDesktopStatus').className = 'notice green'; - if (badgeEl) { - badgeEl.textContent = '已代理'; - badgeEl.className = 'badge active'; - } - if (dotEl) dotEl.className = 'status-dot green'; - } else { - const catalog = - status.managedModelCatalog && status.modelCatalog && status.modelCatalog.exists - ? `;已生成模型目录 ${status.modelCatalog.modelCount || 0} 个模型` - : ''; - $('codexDesktopStatus').textContent = - `官方直连${catalog}${auth};配置路径: ${status.configPath}。Codex Desktop 通过共享 config.toml 和启动时模型目录接管。`; - $('codexDesktopStatus').className = 'notice'; - if (badgeEl) { - badgeEl.textContent = '直连'; - badgeEl.className = 'badge direct'; - } - if (dotEl) dotEl.className = 'status-dot amber'; - } - } catch (err) { - $('codexDesktopStatus').textContent = - `Codex Desktop 状态检查失败:${err && err.message ? err.message : err}`; - $('codexDesktopStatus').className = 'notice'; - if (badgeEl) { - badgeEl.textContent = '失败'; - badgeEl.className = 'badge error'; - } - if (dotEl) dotEl.className = 'status-dot red'; - } -} - -let toolStatusRefreshInFlight = null; -let lastToolStatusRefresh = 0; - -async function refreshAllToolStatuses(options = {}) { - const now = Date.now(); - if (!options.force && toolStatusRefreshInFlight) return toolStatusRefreshInFlight; - if (options.passive && now - lastToolStatusRefresh < 15000) return; - - const finishPerf = perfStart('refresh.toolStatuses'); - toolStatusRefreshInFlight = Promise.allSettled([ - refreshClaudeDesktopStatus(), - refreshClaudeCodeStatus(), - refreshTraeBridgeStatus(), - refreshAntigravityCliStatus(), - refreshGeminiCliStatus(), - refreshCodexStatus(), - refreshCodexDesktopStatus(), - ]).finally(() => { - lastToolStatusRefresh = Date.now(); - toolStatusRefreshInFlight = null; - finishPerf(); - }); - return toolStatusRefreshInFlight; -} - -let isRefreshingEnv = false; -let lastEnvStatusRefresh = 0; -let lastRenderedEnvHtml = ''; -let envPendingRefreshTimer = null; -let lastEnvActionToolIds = []; -const envInstallJobsByTool = new Map(); -const lastEnvToolsById = new Map(); -let envInstallPollTimer = null; - -function getPreferredEnvInstallCommand(tool) { - const toolId = tool && tool.id; - if (toolId === 'claude-code') return 'irm https://claude.ai/install.ps1 | iex'; - if (toolId === 'antigravity-cli') return 'irm https://antigravity.google/cli/install.ps1 | iex'; - if (tool && tool.installCommand) return tool.installCommand; - if (tool && tool.packageName) return `npm i -g ${tool.packageName}@latest`; - return ''; -} - -function scheduleEnvPendingRefresh(delayMs = 2500) { - if (envPendingRefreshTimer) clearTimeout(envPendingRefreshTimer); - envPendingRefreshTimer = setTimeout(() => { - envPendingRefreshTimer = null; - refreshEnvStatus({ passive: true, force: true }); - }, delayMs); -} - -function rememberEnvInstallJobs(results = []) { - let hasRunning = false; - for (const result of results) { - if (!result || !result.jobId || !result.id) continue; - envInstallJobsByTool.set(result.id, { - jobId: result.jobId, - toolId: result.id, - name: result.name || result.id, - status: result.status || 'running', - ok: result.ok, - error: result.error || '', - startedAt: result.startedAt || Date.now(), - endedAt: result.endedAt || null, - installCommand: result.installCommand || '', - usesIrm: !!result.usesIrm, - lastLine: result.lastLine || result.error || result.outputTail || '任务已启动', - outputTail: result.outputTail || '', - }); - if (result.status !== 'succeeded' && result.status !== 'failed') { - hasRunning = true; - } - } - if (hasRunning) startEnvInstallPolling(); - updateEnvInstallProgressPanel(); -} - -function envJobStatusLabel(status) { - if (status === 'succeeded') return '完成'; - if (status === 'failed') return '失败'; - if (status === 'verifying') return '复检中'; - if (status === 'submitting') return '提交中'; - return '执行中'; -} - -function envDataSignature(tools = []) { - return JSON.stringify({ - tools: tools.map((tool) => ({ - id: tool && tool.id, - name: tool && tool.name, - current: tool && tool.current, - latest: tool && tool.latest, - hasUpdate: !!(tool && tool.hasUpdate), - })), - jobs: Array.from(envInstallJobsByTool.values()).map((job) => ({ - toolId: job && job.toolId, - status: job && job.status, - lastLine: job && job.lastLine, - outputTail: job && job.outputTail, - installCommand: job && job.installCommand, - usesIrm: !!(job && job.usesIrm), - })), - }); -} - -function setGridNotice(element) { - if (!element) return; - element.style.gridColumn = '1 / -1'; -} - -function createEnvCodeBlock(text, maxHeight = '42px') { - const code = document.createElement('code'); - code.style.display = 'block'; - code.style.maxHeight = maxHeight; - code.style.overflow = 'auto'; - code.style.whiteSpace = 'pre-wrap'; - code.style.wordBreak = 'break-word'; - code.style.color = 'var(--text)'; - code.style.fontFamily = 'var(--font-mono)'; - code.style.fontSize = '11.5px'; - code.style.lineHeight = '1.35'; - code.textContent = text; - return code; -} - -function createEnvProgressItem(job) { - const failed = job.status === 'failed'; - const succeeded = job.status === 'succeeded'; - const item = document.createElement('div'); - item.style.display = 'grid'; - item.style.gridTemplateColumns = 'minmax(120px, 180px) minmax(100px, 140px) 1fr'; - item.style.gap = '10px'; - item.style.alignItems = 'start'; - item.style.padding = '8px 10px'; - item.style.border = `1px solid ${failed ? 'rgba(239, 68, 68, 0.35)' : 'var(--border)'}`; - item.style.borderRadius = '6px'; - item.style.background = succeeded ? 'rgba(16, 185, 129, 0.08)' : 'var(--card-bg)'; - - const info = document.createElement('div'); - const name = document.createElement('div'); - name.style.fontWeight = '700'; - name.style.color = 'var(--text)'; - name.textContent = job.name || job.toolId || ''; - const method = document.createElement('div'); - method.className = 'muted'; - method.style.fontSize = '12px'; - method.textContent = job.usesIrm ? '官方 irm 安装脚本' : 'npm 全局安装'; - info.append(name, method); - - const status = document.createElement('strong'); - status.style.color = failed ? '#ef4444' : 'var(--text)'; - status.textContent = envJobStatusLabel(job.status); - - item.append( - info, - status, - createEnvCodeBlock( - job.lastLine || job.outputTail || job.installCommand || '等待输出...', - '48px' - ) - ); - return item; -} - -function updateEnvInstallProgressPanel() { - const panel = $('envInstallProgressPanel'); - if (!panel) return; - const jobs = Array.from(envInstallJobsByTool.values()).sort( - (a, b) => (b.startedAt || 0) - (a.startedAt || 0) - ); - if (jobs.length === 0) { - panel.style.display = 'none'; - clearElement(panel); - return; - } - - panel.style.display = 'block'; - clearElement(panel); - const header = document.createElement('div'); - header.style.display = 'flex'; - header.style.alignItems = 'center'; - header.style.justifyContent = 'space-between'; - header.style.gap = '12px'; - header.style.marginBottom = '8px'; - const title = document.createElement('strong'); - title.style.fontSize = '13px'; - title.style.color = 'var(--text)'; - title.textContent = '安装/升级进度'; - const hint = document.createElement('span'); - hint.className = 'muted'; - hint.style.fontSize = '12px'; - hint.textContent = '显示最近任务输出,完成后会自动刷新版本状态'; - header.append(title, hint); - - const list = document.createElement('div'); - list.style.display = 'grid'; - list.style.gap = '8px'; - jobs.forEach((job) => list.appendChild(createEnvProgressItem(job))); - panel.append(header, list); -} - -function markEnvInstallSubmitting(toolIds = []) { - const now = Date.now(); - for (const toolId of toolIds.filter(Boolean)) { - const tool = lastEnvToolsById.get(toolId) || { id: toolId, name: toolId }; - const installCommand = getPreferredEnvInstallCommand(tool); - envInstallJobsByTool.set(toolId, { - jobId: `pending-${toolId}-${now}`, - toolId, - name: tool.name || toolId, - status: 'submitting', - installCommand, - usesIrm: installCommand.startsWith('irm '), - startedAt: now, - lastLine: '正在提交安装任务...', - outputTail: '', - }); - } - updateEnvInstallProgressPanel(); - lastRenderedEnvHtml = ''; -} - -function finalizeEnvInstallSubmission(toolIds = [], results = [], fallbackError = '') { - const byId = new Map((results || []).map((result) => [result && result.id, result])); - for (const toolId of toolIds.filter(Boolean)) { - const result = byId.get(toolId); - if (result && result.jobId) continue; - const current = envInstallJobsByTool.get(toolId); - if (!current || !String(current.jobId || '').startsWith('pending-')) continue; - const failed = fallbackError || (result && result.ok === false); - envInstallJobsByTool.set(toolId, { - ...current, - status: failed ? 'failed' : 'succeeded', - endedAt: Date.now(), - error: fallbackError || (result && result.error) || '', - lastLine: failed - ? fallbackError || (result && result.error) || '安装任务失败' - : '安装/升级完成', - }); - } - updateEnvInstallProgressPanel(); -} - -async function pollEnvInstallJobs() { - const jobIds = Array.from(envInstallJobsByTool.values()) - .filter((job) => job && job.jobId && job.status !== 'succeeded' && job.status !== 'failed') - .map((job) => job.jobId); - if (jobIds.length === 0) { - if (envInstallPollTimer) clearInterval(envInstallPollTimer); - envInstallPollTimer = null; - updateEnvInstallProgressPanel(); - return; - } - - const res = await window.proxyManager.envInstallJobs(jobIds); - if (!res.ok || !Array.isArray(res.jobs)) return; - let changed = false; - let allDone = true; - for (const job of res.jobs) { - if (!job || !job.toolId) continue; - envInstallJobsByTool.set(job.toolId, job); - changed = true; - if (job.status !== 'succeeded' && job.status !== 'failed') allDone = false; - } - if (changed) { - updateEnvInstallProgressPanel(); - lastRenderedEnvHtml = ''; - refreshEnvStatus({ passive: true, force: true }); - } - if (allDone) { - if (envInstallPollTimer) clearInterval(envInstallPollTimer); - envInstallPollTimer = null; - setTimeout(() => refreshEnvStatus({ passive: true, force: true }), 1200); - } -} - -function startEnvInstallPolling() { - if (envInstallPollTimer) return; - envInstallPollTimer = setInterval(() => { - pollEnvInstallJobs().catch((err) => console.error('环境安装进度轮询失败:', err)); - }, 1200); - pollEnvInstallJobs().catch((err) => console.error('环境安装进度轮询失败:', err)); -} - -function createEnvSkeletonCard() { - const card = document.createElement('div'); - card.className = 'card'; - card.style.padding = '16px'; - card.style.display = 'flex'; - card.style.flexDirection = 'column'; - card.style.minHeight = '120px'; - card.style.opacity = '0.7'; - - const top = document.createElement('div'); - top.style.display = 'flex'; - top.style.justifyContent = 'space-between'; - top.style.alignItems = 'flex-start'; - top.style.marginBottom = '12px'; - const identity = document.createElement('div'); - identity.style.display = 'flex'; - identity.style.alignItems = 'center'; - identity.style.gap = '8px'; - const dot = document.createElement('span'); - dot.className = 'status-dot'; - dot.style.margin = '0'; - dot.style.background = 'var(--border)'; - const title = document.createElement('strong'); - title.style.width = '80px'; - title.style.height = '16px'; - title.style.background = 'var(--border)'; - title.style.borderRadius = '2px'; - identity.append(dot, title); - top.appendChild(identity); - - const rows = document.createElement('div'); - rows.style.display = 'flex'; - rows.style.flexDirection = 'column'; - rows.style.gap = '8px'; - ['60%', '80%'].forEach((width) => { - const row = document.createElement('div'); - row.style.display = 'flex'; - row.style.justifyContent = 'space-between'; - row.style.height = '14px'; - row.style.background = 'var(--border)'; - row.style.borderRadius = '2px'; - row.style.width = width; - rows.appendChild(row); - }); - card.append(top, rows); - return card; -} - -function renderEnvSkeleton(grid) { - clearElement(grid); - for (let i = 0; i < 6; i++) grid.appendChild(createEnvSkeletonCard()); -} - -function createEnvActionButton(tool, isInstalled, isJobRunning) { - const button = document.createElement('button'); - button.style.margin = '0'; - button.style.padding = '4px 8px'; - button.style.fontSize = '12px'; - button.style.height = '26px'; - button.style.lineHeight = '1'; - if (isJobRunning) { - button.className = 'light'; - button.disabled = true; - button.style.opacity = '0.7'; - button.textContent = '执行中'; - return button; - } - if (!isInstalled || tool.hasUpdate) { - button.className = 'primary'; - button.dataset.upgradeTool = tool.id || ''; - button.dataset.upgradeAction = isInstalled ? 'upgrade' : 'install'; - button.textContent = isInstalled ? '升级' : '安装'; - return button; - } - button.className = 'light'; - button.disabled = true; - button.style.opacity = '0.5'; - button.textContent = '最新'; - return button; -} - -function createEnvProgressBlock(installJob) { - if (!installJob) return null; - const failed = installJob.status === 'failed'; - const block = document.createElement('div'); - block.style.display = 'flex'; - block.style.flexDirection = 'column'; - block.style.gap = '4px'; - block.style.marginTop = '4px'; - block.style.padding = '8px 10px'; - block.style.border = `1px solid ${failed ? 'rgba(239, 68, 68, 0.35)' : 'var(--border)'}`; - block.style.borderRadius = '6px'; - block.style.background = - installJob.status === 'succeeded' ? 'rgba(16, 185, 129, 0.08)' : 'var(--bg-hover)'; - - const line = document.createElement('div'); - line.style.display = 'flex'; - line.style.justifyContent = 'space-between'; - line.style.gap = '8px'; - line.style.alignItems = 'center'; - const label = document.createElement('span'); - label.className = 'muted'; - label.textContent = '安装进度'; - const status = document.createElement('strong'); - status.style.color = failed ? '#ef4444' : 'var(--text)'; - status.textContent = envJobStatusLabel(installJob.status); - line.append(label, status); - - const method = document.createElement('div'); - method.className = 'muted'; - method.style.fontSize = '12px'; - method.textContent = installJob.usesIrm ? '官方 irm 安装脚本' : 'npm 全局安装'; - block.append( - line, - method, - createEnvCodeBlock(installJob.lastLine || installJob.outputTail || '等待输出...') - ); - return block; -} - -function createEnvInfoRow(labelText, valueText) { - const row = document.createElement('div'); - row.style.display = 'flex'; - row.style.justifyContent = 'space-between'; - const label = document.createElement('span'); - label.className = 'muted'; - label.textContent = labelText; - const value = document.createElement('strong'); - value.style.color = 'var(--text)'; - value.textContent = valueText; - row.append(label, value); - return row; -} - -function createEnvToolCard(tool) { - const isInstalled = !!tool.current; - const installJob = envInstallJobsByTool.get(tool.id); - const isJobRunning = - installJob && installJob.status !== 'succeeded' && installJob.status !== 'failed'; - const card = document.createElement('div'); - card.className = 'card env-card'; - card.style.padding = '16px'; - card.style.display = 'flex'; - card.style.flexDirection = 'column'; - card.style.minHeight = '142px'; - card.style.border = `1px solid ${!isInstalled || tool.hasUpdate ? 'var(--primary)' : 'var(--border)'}`; - card.style.background = - !isInstalled || tool.hasUpdate ? 'rgba(var(--primary-rgb), 0.05)' : 'var(--card-bg)'; - - const header = document.createElement('div'); - header.style.display = 'flex'; - header.style.justifyContent = 'space-between'; - header.style.alignItems = 'flex-start'; - header.style.marginBottom = '12px'; - const identity = document.createElement('div'); - identity.style.display = 'flex'; - identity.style.alignItems = 'center'; - identity.style.gap = '8px'; - const dot = document.createElement('span'); - dot.className = `status-dot ${isInstalled ? 'green' : 'amber'}`; - dot.style.margin = '0'; - const name = document.createElement('strong'); - name.style.fontSize = '15px'; - name.style.color = 'var(--text)'; - name.textContent = tool.name || tool.id || ''; - identity.append(dot, name); - header.append(identity, createEnvActionButton(tool, isInstalled, isJobRunning)); - - const body = document.createElement('div'); - body.style.display = 'flex'; - body.style.flexDirection = 'column'; - body.style.gap = '6px'; - body.style.fontSize = '13px'; - body.style.flex = '1'; - body.append( - createEnvInfoRow('当前版本', tool.current || '未安装'), - createEnvInfoRow('最新版本', tool.latest || '加载中...') - ); - - const commandBlock = document.createElement('div'); - commandBlock.style.display = 'flex'; - commandBlock.style.flexDirection = 'column'; - commandBlock.style.gap = '4px'; - commandBlock.style.marginTop = '4px'; - const commandLabel = document.createElement('span'); - commandLabel.className = 'muted'; - commandLabel.textContent = '安装命令'; - const command = document.createElement('code'); - command.style.display = 'block'; - command.style.padding = '6px 8px'; - command.style.border = '1px solid var(--border)'; - command.style.borderRadius = '4px'; - command.style.background = 'var(--bg-hover)'; - command.style.color = 'var(--text)'; - command.style.fontFamily = 'var(--font-mono)'; - command.style.fontSize = '12px'; - command.style.lineHeight = '1.35'; - command.style.overflowX = 'auto'; - command.style.whiteSpace = 'nowrap'; - command.textContent = getPreferredEnvInstallCommand(tool); - commandBlock.append(commandLabel, command); - body.appendChild(commandBlock); - const progress = createEnvProgressBlock(installJob); - if (progress) body.appendChild(progress); - card.append(header, body); - return card; -} - -async function refreshEnvStatus(options = {}) { - const now = Date.now(); - if ( - options.passive && - !options.force && - lastRenderedEnvHtml && - now - lastEnvStatusRefresh < 30000 - ) - return; - if (isRefreshingEnv) return; - isRefreshingEnv = true; - const finishPerf = perfStart('refresh.envStatus'); - - const grid = $('envGrid'); - const btnRefresh = $('btnRefreshEnv'); - const svgRefresh = $('svgRefreshEnv'); - const btnUpgradeAll = $('btnUpgradeAllEnv'); - - if (btnRefresh) btnRefresh.disabled = true; - if (svgRefresh) svgRefresh.style.animation = 'spin 1s linear infinite'; - - // 预渲染加载骨架;被动刷新已有结果时不闪烁列表。 - if (!options.passive || !lastRenderedEnvHtml) { - renderEnvSkeleton(grid); - } - - try { - const res = await window.proxyManager.envCheck(); - if (!res.ok) throw new Error(res.error || '检查失败'); - - let updateCount = 0; - let installCount = 0; - const actionToolIds = []; - const tools = Array.isArray(res.tools) ? res.tools : []; - tools.forEach((tool) => { - if (tool && tool.id) lastEnvToolsById.set(tool.id, tool); - const isInstalled = !!(tool && tool.current); - if (!isInstalled) installCount++; - if (tool && tool.hasUpdate) updateCount++; - if (tool && (!isInstalled || tool.hasUpdate)) actionToolIds.push(tool.id); - }); - lastEnvActionToolIds = actionToolIds.filter(Boolean); - const envSignature = envDataSignature(tools); - if (envSignature !== lastRenderedEnvHtml) { - grid.replaceChildren(...tools.map((tool) => createEnvToolCard(tool || {}))); - lastRenderedEnvHtml = envSignature; - } - - const actionCount = updateCount + installCount; - btnUpgradeAll.textContent = - actionCount > 0 ? `全部安装/升级 (${actionCount})` : '全部安装/升级 (0)'; - btnUpgradeAll.disabled = actionCount === 0; - lastEnvStatusRefresh = Date.now(); - if (res.latestPending) { - scheduleEnvPendingRefresh(); - } - } catch (err) { - showToast('环境检查刷新失败: ' + err.message, 'error'); - const notice = document.createElement('div'); - notice.className = 'notice'; - setGridNotice(notice); - notice.textContent = `刷新失败,请检查网络或代理服务器运行状态:${err.message}`; - grid.replaceChildren(notice); - } finally { - isRefreshingEnv = false; - if (btnRefresh) btnRefresh.disabled = false; - if (svgRefresh) { - svgRefresh.style.animation = ''; - void svgRefresh.offsetWidth; - } - finishPerf(); - } -} - -window.upgradeSingleTool = async function (toolId, action = 'upgrade') { - const isInstall = action === 'install'; - const confirmed = await confirmAction({ - title: isInstall ? '安装助手工具' : '升级助手工具', - message: `确认要${isInstall ? '安装' : '升级'}该 AI 命令行工具吗?这将在后台执行全局安装命令,耗时可能较长。`, - confirmText: isInstall ? '确定安装' : '确定升级', - }); - if (!confirmed) return; - - markEnvInstallSubmitting([toolId]); - lastRenderedEnvHtml = ''; - refreshEnvStatus({ passive: true, force: true }); - showToast(isInstall ? '正在安装工具,请稍候...' : '正在升级工具,请稍候...', 'success'); - try { - const res = await window.proxyManager.envUpgrade(toolId); - if (!res.ok) throw new Error(res.error || (isInstall ? '安装失败' : '升级失败')); - - const result = res.results && res.results[0]; - if (result && !result.ok) { - throw new Error(result.error || '安装失败'); - } - - rememberEnvInstallJobs(res.results || []); - finalizeEnvInstallSubmission([toolId], res.results || []); - showToast( - isInstall ? '安装任务已启动,正在显示进度...' : '升级任务已启动,正在显示进度...', - 'success' - ); - refreshEnvStatus({ passive: true, force: true }); - } catch (err) { - finalizeEnvInstallSubmission([toolId], [], err.message); - showToast((isInstall ? '工具安装失败:' : '工具升级失败:') + err.message, 'error'); - } -}; - -async function upgradeAllEnv() { - const confirmed = await confirmAction({ - title: '全部安装/升级', - message: - '确认要安装缺失工具并升级有更新的 AI 命令行工具吗?这将在后台依次串行执行全局安装命令,需要数分钟时间。', - confirmText: '全部安装/升级', - }); - if (!confirmed) return; +// app-tool-status.js owns this domain. - showToast('正在执行全部升级,可能需要几分钟时间,请勿关闭本管理器...', 'success'); - const btnUpgradeAll = $('btnUpgradeAllEnv'); - if (btnUpgradeAll) { - btnUpgradeAll.disabled = true; - btnUpgradeAll.textContent = '正在安装/升级...'; - } - const batchToolIds = lastEnvActionToolIds.slice(); - markEnvInstallSubmitting(batchToolIds); - lastRenderedEnvHtml = ''; - refreshEnvStatus({ passive: true, force: true }); - - try { - const res = await window.proxyManager.envUpgrade(batchToolIds); - if (!res.ok) throw new Error(res.error || '升级失败'); - - rememberEnvInstallJobs(res.results || []); - finalizeEnvInstallSubmission(batchToolIds, res.results || []); - const failed = res.results.filter((r) => !r.ok); - if (failed.length > 0) { - showToast(`部分包升级失败: ${failed.map((f) => f.name).join(', ')}`, 'error'); - } else { - showToast('安装/升级任务已启动,正在显示进度...', 'success'); - } - refreshEnvStatus({ passive: true, force: true }); - } catch (err) { - finalizeEnvInstallSubmission(batchToolIds, [], err.message); - showToast('全部升级失败:' + err.message, 'error'); - } finally { - if (btnUpgradeAll) btnUpgradeAll.disabled = false; - } -} - -function bindEnvControls() { - const refreshButton = $('btnRefreshEnv'); - const upgradeAllButton = $('btnUpgradeAllEnv'); - const diagnosticsButton = $('btnDiagnosticsConflicts'); - const grid = $('envGrid'); - - if (refreshButton) { - refreshButton.onclick = () => refreshEnvStatus(); - refreshButton.dataset.bound = 'true'; - } - if (upgradeAllButton) { - upgradeAllButton.onclick = () => upgradeAllEnv(); - upgradeAllButton.dataset.bound = 'true'; - } - if (diagnosticsButton) { - diagnosticsButton.onclick = async () => { - showToast('正在诊断安装冲突与分析包依赖...', 'success'); - setTimeout(() => { - showToast('依赖分析完毕,未检测到包冲突!所有 CLI 工具全局运行环境良好。', 'success'); - }, 1000); - }; - } - if (grid) { - grid.onclick = (event) => { - if (!(event.target instanceof Element)) return; - const button = event.target.closest('[data-upgrade-tool]'); - if (!button || !grid.contains(button)) return; - window.upgradeSingleTool(button.dataset.upgradeTool, button.dataset.upgradeAction); - }; - grid.dataset.bound = 'true'; - } -} +// app-environment.js owns this domain. function bindCriticalFrontendControls(stage = 'critical') { markFrontendInit(`${stage}:start`); @@ -5437,7 +3654,7 @@ $('copyIdeConfigBtn').onclick = async () => { const scheme = primaryEntry.tls === false ? 'http' : 'https'; const host = primaryEntry.host === '0.0.0.0' ? '127.0.0.1' : primaryEntry.host; const baseUrl = `${scheme}://${host || 'localhost'}:${primaryEntry.listenPort || 3443}${primaryEntry.basePath || '/v1'}`; - let nodeExtraCaCerts = ''; + let nodeExtraCaCerts; try { const status = await promiseWithTimeout( window.proxyManager.certStatus(), @@ -5959,8 +4176,8 @@ function bindCodexOfficialLoginButton(buttonId, surface) { const nativeLauncher = window.proxyManager.launchCodexLoginScript; const result = await api({ logout: true, launch: nativeLauncher ? false : true }); let launchResult = null; - if (result.ok && nativeLauncher && result.scriptPath) { - launchResult = await nativeLauncher(result.scriptPath); + if (result.ok && nativeLauncher) { + launchResult = await nativeLauncher(); if (!launchResult || !launchResult.ok) { launchResult = await api({ logout: true, launch: true }); } @@ -6632,335 +4849,7 @@ if (saveTplBtn) { }; } -// --- MCP Hub UI Logic --- - -let mcpHubConfig = { servers: [], routing: [] }; - -const GLM_MCP_TEMPLATES = { - webSearchPrime: { - id: 'glm-web-search', - url: 'https://open.bigmodel.cn/api/mcp/web_search_prime/mcp', - prefix: 'glm_search', - injectAll: true, - }, -}; - -async function initMcpHubView() { - const container = $('mcpHubView'); - if (!container || container.dataset.initialized) { - refreshMcpHub(); - return; - } - container.dataset.initialized = 'true'; - - $('refreshMcpHub').onclick = () => refreshMcpHub(); - $('addMcpServer').onclick = () => addMcpServer(); - const addGlmSearchButton = $('addGlmSearchMcpServer'); - if (addGlmSearchButton) addGlmSearchButton.onclick = () => addGlmSearchMcpServer(); - $('saveMcpHub').onclick = () => saveMcpHub(); - - refreshMcpHub(); -} - -async function refreshMcpHub() { - const status = $('mcpHubStatus'); - status.textContent = '正在加载 MCP 配置...'; - - try { - const res = await window.proxyManager.mcpHubGet(); - if (res.ok) { - mcpHubConfig = res.mcpHub; - renderMcpServers(); - status.textContent = `已加载 ${mcpHubConfig.servers.length} 个服务器,${mcpHubConfig.routing.length} 条路由规则。`; - } else { - status.textContent = '加载 MCP 配置失败:' + (res.error || '未知错误'); - } - - const logsRes = await window.proxyManager.mcpHubLogs(); - if (logsRes.ok) { - renderMcpLogs(logsRes.logs); - } - } catch (err) { - status.textContent = '加载失败:' + err.message; - } -} - -function renderMcpServers() { - const container = $('mcpServersList'); - clearElement(container); - if (!mcpHubConfig || !mcpHubConfig.servers || mcpHubConfig.servers.length === 0) { - container.appendChild(createMcpServersEmptyElement()); - return; - } - - container.appendChild(createMcpServersTableElement(mcpHubConfig.servers)); -} - -function createMcpServersEmptyElement() { - const empty = document.createElement('div'); - empty.className = 'muted'; - empty.style.padding = '20px'; - empty.style.textAlign = 'center'; - empty.textContent = '暂无已配置的 MCP 服务器。'; - return empty; -} - -function createMcpServersTableElement(servers) { - const wrapper = document.createElement('div'); - wrapper.className = 'route-table'; - const head = document.createElement('div'); - head.className = 'route-table-head'; - const headTable = document.createElement('table'); - const thead = document.createElement('thead'); - const headerRow = document.createElement('tr'); - [ - ['服务器 ID', ''], - ['URL (HTTP/SSE)', ''], - ['前缀', ''], - ['Bearer Token', '180px'], - ['注入', '60px'], - ['操作', '80px'], - ].forEach(([label, width]) => { - const th = document.createElement('th'); - th.textContent = label; - if (width) th.style.width = width; - headerRow.appendChild(th); - }); - thead.appendChild(headerRow); - headTable.appendChild(thead); - head.appendChild(headTable); - - const scroll = document.createElement('div'); - scroll.className = 'route-table-scroll'; - const bodyTable = document.createElement('table'); - const tbody = document.createElement('tbody'); - servers.forEach((server, index) => tbody.appendChild(createMcpServerRow(server, index))); - bodyTable.appendChild(tbody); - scroll.appendChild(bodyTable); - wrapper.append(head, scroll); - return wrapper; -} - -function createMcpServerInput(server, index, field, placeholder = '') { - const input = document.createElement('input'); - input.type = field === 'bearerToken' ? 'password' : 'text'; - input.value = String(server[field] || ''); - input.className = 'table-input'; - if (placeholder) input.placeholder = placeholder; - input.addEventListener('change', () => updateMcpServer(index, field, input.value)); - return input; -} - -function createMcpServerRow(server, index) { - const row = document.createElement('tr'); - - const idCell = document.createElement('td'); - idCell.appendChild(createMcpServerInput(server, index, 'id')); - row.appendChild(idCell); - - const urlCell = document.createElement('td'); - urlCell.appendChild(createMcpServerInput(server, index, 'url', 'http://...')); - row.appendChild(urlCell); - - const prefixCell = document.createElement('td'); - prefixCell.appendChild(createMcpServerInput(server, index, 'prefix', '可选')); - row.appendChild(prefixCell); - - const tokenCell = document.createElement('td'); - tokenCell.appendChild(createMcpServerInput(server, index, 'bearerToken', 'GLM Coding Plan Key')); - row.appendChild(tokenCell); - - const injectCell = document.createElement('td'); - injectCell.style.textAlign = 'center'; - const injectCheckbox = document.createElement('input'); - injectCheckbox.type = 'checkbox'; - injectCheckbox.checked = Boolean(server.injectAll); - injectCheckbox.addEventListener('change', () => - updateMcpServer(index, 'injectAll', injectCheckbox.checked) - ); - injectCell.appendChild(injectCheckbox); - row.appendChild(injectCell); - - const actionCell = document.createElement('td'); - actionCell.style.textAlign = 'right'; - const deleteButton = document.createElement('button'); - deleteButton.className = 'danger'; - deleteButton.style.padding = '2px 8px'; - deleteButton.style.fontSize = '12px'; - deleteButton.style.margin = '0'; - deleteButton.textContent = '删除'; - deleteButton.addEventListener('click', () => deleteMcpServer(index)); - actionCell.appendChild(deleteButton); - row.appendChild(actionCell); - - return row; -} - -function updateMcpServer(index, field, value) { - if (mcpHubConfig.servers[index]) { - mcpHubConfig.servers[index][field] = value; - } -} - -function deleteMcpServer(index) { - mcpHubConfig.servers.splice(index, 1); - renderMcpServers(); -} - -function addMcpServer() { - if (!mcpHubConfig.servers) mcpHubConfig.servers = []; - mcpHubConfig.servers.push({ - id: 'mcp-server-' + Math.random().toString(36).substring(2, 6), - url: 'http://localhost:3000/sse', - prefix: '', - injectAll: true, - }); - renderMcpServers(); -} - -function addGlmSearchMcpServer() { - if (!mcpHubConfig.servers) mcpHubConfig.servers = []; - if (!mcpHubConfig.routing) mcpHubConfig.routing = []; - - const template = GLM_MCP_TEMPLATES.webSearchPrime; - const existing = mcpHubConfig.servers.find((server) => server && server.id === template.id); - if (existing) { - Object.assign(existing, { - url: template.url, - prefix: existing.prefix || template.prefix, - injectAll: true, - }); - } else { - mcpHubConfig.servers.push({ - ...template, - bearerToken: '', - }); - } - - const hasRoute = mcpHubConfig.routing.some( - (route) => route && route.serverId === template.id && route.pattern === '^glm_search_' - ); - if (!hasRoute) { - mcpHubConfig.routing.push({ pattern: '^glm_search_', serverId: template.id }); - } - renderMcpServers(); - const status = $('mcpHubStatus'); - if (status) { - status.textContent = - '已添加 GLM 联网搜索 MCP 模板。请在 Bearer Token 中填入 GLM Coding Plan API Key 后保存。'; - } -} - -function createMcpLogsEmptyElement() { - const empty = document.createElement('div'); - empty.className = 'muted'; - empty.textContent = '暂无执行记录...'; - return empty; -} - -function createMcpLogEntryElement(log) { - const entry = document.createElement('div'); - entry.className = 'log-entry'; - entry.style.marginBottom = '12px'; - entry.style.borderBottom = '1px solid var(--border)'; - entry.style.paddingBottom = '8px'; - - const header = document.createElement('div'); - header.style.display = 'flex'; - header.style.justifyContent = 'space-between'; - header.style.alignItems = 'center'; - - const title = document.createElement('div'); - const time = document.createElement('span'); - time.className = 'muted'; - time.textContent = `[${new Date(log.time).toLocaleTimeString()}] `; - title.appendChild(time); - - const server = document.createElement('strong'); - server.style.color = 'var(--text)'; - server.textContent = log.serverId || '-'; - title.appendChild(server); - title.appendChild(document.createTextNode(': ')); - - const tool = document.createElement('code'); - tool.style.color = 'var(--primary)'; - tool.style.fontWeight = '600'; - tool.textContent = log.toolName || '-'; - title.appendChild(tool); - - const badge = document.createElement('span'); - badge.className = `badge ${log.status === 'error' ? 'red' : 'green'}`; - badge.textContent = `${log.status === 'error' ? '失败' : '成功'} (${log.elapsed || 0}ms)`; - - header.appendChild(title); - header.appendChild(badge); - entry.appendChild(header); - - if (log.error) { - const error = document.createElement('div'); - error.style.color = '#ef4444'; - error.style.marginTop = '4px'; - error.textContent = `Error: ${log.error}`; - entry.appendChild(error); - } - - const details = document.createElement('details'); - details.style.marginTop = '6px'; - const summary = document.createElement('summary'); - summary.style.cursor = 'pointer'; - summary.style.color = 'var(--primary)'; - summary.style.fontSize = '11px'; - summary.style.userSelect = 'none'; - summary.textContent = '查看参数/结果'; - - const pre = document.createElement('pre'); - pre.style.background = 'rgba(0,0,0,0.05)'; - pre.style.padding = '8px'; - pre.style.marginTop = '4px'; - pre.style.borderRadius = '4px'; - pre.style.fontSize = '11.5px'; - pre.style.whiteSpace = 'pre-wrap'; - pre.style.wordBreak = 'break-all'; - pre.textContent = JSON.stringify({ input: log.input, result: log.result }, null, 2); - - details.appendChild(summary); - details.appendChild(pre); - entry.appendChild(details); - return entry; -} - -function renderMcpLogs(logs) { - const container = $('mcpExecutionLogs'); - container.textContent = ''; - if (!logs || logs.length === 0) { - container.appendChild(createMcpLogsEmptyElement()); - return; - } - - logs.forEach((log) => container.appendChild(createMcpLogEntryElement(log))); -} - -async function saveMcpHub() { - const btn = $('saveMcpHub'); - const origText = btn.textContent; - btn.disabled = true; - btn.textContent = '正在保存...'; - - try { - const res = await window.proxyManager.mcpHubSave({ mcpHub: mcpHubConfig }); - if (res.ok) { - showToast('MCP 配置已保存', 'success'); - refreshMcpHub(); - } else { - showToast('保存失败:' + (res.error || '未知错误'), 'error'); - } - } catch (err) { - showToast('保存出错:' + err.message, 'error'); - } finally { - btn.disabled = false; - btn.textContent = origText; - } -} +// app-mcp.js owns this domain. bindCriticalFrontendControls('final'); markFrontendInit('load:start'); diff --git a/tauri-ui/electron-ui.html b/tauri-ui/electron-ui.html index c2fd56c5..224a3cf7 100644 --- a/tauri-ui/electron-ui.html +++ b/tauri-ui/electron-ui.html @@ -1202,6 +1202,9 @@

确认操作

+ + + diff --git a/tauri-ui/index.html b/tauri-ui/index.html index 71b55cba..cff12623 100644 --- a/tauri-ui/index.html +++ b/tauri-ui/index.html @@ -30,6 +30,9 @@ + + + diff --git a/test/app-bridge.test.js b/test/app-bridge.test.js new file mode 100644 index 00000000..829f0b9e --- /dev/null +++ b/test/app-bridge.test.js @@ -0,0 +1,37 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const source = fs.readFileSync( + path.join(__dirname, '..', 'tauri-ui', 'app-bridge.js'), + 'utf8' +); + +assert.ok( + source.includes("invoke('get_management_token_native'"), + 'Tauri management requests must resolve the token through the native command' +); +assert.ok( + source.includes("localStorage.removeItem('hpm_mgmt_token')"), + 'startup must remove legacy persisted management tokens' +); +assert.ok( + !source.includes("localStorage.getItem('hpm_mgmt_token')"), + 'management tokens must never be read from localStorage' +); +assert.ok( + !source.includes("localStorage.setItem('hpm_mgmt_token'"), + 'management tokens must never be persisted to localStorage' +); +assert.ok( + source.includes("invoke('launch_codex_login_script')"), + 'the native Codex login launcher must resolve its own fixed script path' +); +assert.ok( + !source.includes("invoke('launch_codex_login_script',"), + 'the renderer must not provide an executable script path to the native launcher' +); + +console.log('[app-bridge.test] ok'); diff --git a/test/app-ux.test.js b/test/app-ux.test.js index b68be42a..7ab255af 100644 --- a/test/app-ux.test.js +++ b/test/app-ux.test.js @@ -6,6 +6,12 @@ const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); +function readAppSource() { + return ['app-tool-status.js', 'app-environment.js', 'app-mcp.js', 'app.js'] + .map((file) => fs.readFileSync(path.join(__dirname, '..', 'tauri-ui', file), 'utf8')) + .join('\n'); +} + function buildSandbox() { const listeners = new Map(); const localStorage = { @@ -293,7 +299,7 @@ function tick(ms = 0) { path.join(__dirname, '..', 'tauri-ui', 'electron-ui.html'), 'utf8' ); - const app = fs.readFileSync(path.join(__dirname, '..', 'tauri-ui', 'app.js'), 'utf8'); + const app = readAppSource(); assert.ok( html.includes('id="testClaudeDesktopProcess"'), 'Claude Desktop process self-test button exists' @@ -310,11 +316,14 @@ function tick(ms = 0) { // ─── 8. MCP Hub uses delegated DOM handlers, not inline JS ───── { - const app = fs.readFileSync(path.join(__dirname, '..', 'tauri-ui', 'app.js'), 'utf8'); - const start = app.indexOf('function renderMcpServers()'); - const end = app.indexOf('function renderMcpLogs(', start); + const mcpModule = fs.readFileSync( + path.join(__dirname, '..', 'tauri-ui', 'app-mcp.js'), + 'utf8' + ); + const start = mcpModule.indexOf('function renderMcpServers()'); + const end = mcpModule.indexOf('function renderMcpLogs(', start); assert.ok(start > 0 && end > start, 'MCP Hub render section found'); - const mcpSection = app.slice(start, end); + const mcpSection = mcpModule.slice(start, end); assert.ok( !/\son(?:click|change|input|load)=/i.test(mcpSection), 'MCP Hub has no inline event handlers' @@ -325,7 +334,7 @@ function tick(ms = 0) { // ─── 9. Prompt template lists avoid dynamic HTML strings ─────── { - const app = fs.readFileSync(path.join(__dirname, '..', 'tauri-ui', 'app.js'), 'utf8'); + const app = readAppSource(); const presetStart = app.indexOf('function loadPresetPrompts()'); const presetEnd = app.indexOf('function loadTemplates()', presetStart); assert.ok(presetStart > 0 && presetEnd > presetStart, 'preset prompt render section found'); @@ -358,13 +367,16 @@ function tick(ms = 0) { // ─── 10. MCP execution logs avoid dynamic HTML strings ───────── { - const app = fs.readFileSync(path.join(__dirname, '..', 'tauri-ui', 'app.js'), 'utf8'); - const start = app.indexOf('function renderMcpLogs(logs)'); - const end = app.indexOf('async function saveMcpHub()', start); + const mcpModule = fs.readFileSync( + path.join(__dirname, '..', 'tauri-ui', 'app-mcp.js'), + 'utf8' + ); + const start = mcpModule.indexOf('function renderMcpLogs(logs)'); + const end = mcpModule.indexOf('async function saveMcpHub()', start); assert.ok(start > 0 && end > start, 'MCP log render section found'); - const logsSection = app.slice(start, end); + const logsSection = mcpModule.slice(start, end); assert.ok( - app.includes('function createMcpLogEntryElement(log)'), + mcpModule.includes('function createMcpLogEntryElement(log)'), 'MCP log entry DOM helper exists' ); assert.ok( @@ -376,11 +388,17 @@ function tick(ms = 0) { !logsSection.includes('escapeHtml(log.'), 'MCP logs do not build escaped HTML strings' ); + assert.ok( + !/\blog\.input\b/.test(mcpModule) && + !/\blog\.result\b/.test(mcpModule) && + !/\blog\.error\b/.test(mcpModule), + 'MCP log UI consumes metadata only and cannot render captured content' + ); } // ─── 11. Claude Desktop model table avoids dynamic HTML strings ─ { - const app = fs.readFileSync(path.join(__dirname, '..', 'tauri-ui', 'app.js'), 'utf8'); + const app = readAppSource(); const start = app.indexOf('function renderClaudeDesktopModels()'); const end = app.indexOf('function syncClaudeDesktopRoutesToModelRoutes()', start); assert.ok(start > 0 && end > start, 'Claude Desktop model render section found'); @@ -405,7 +423,7 @@ function tick(ms = 0) { // ─── 12. Dynamic select/datalist options use DOM APIs ────────── { - const app = fs.readFileSync(path.join(__dirname, '..', 'tauri-ui', 'app.js'), 'utf8'); + const app = readAppSource(); assert.ok( app.includes('function appendOption(select, value, label = value)'), 'shared option DOM helper exists' @@ -428,7 +446,7 @@ function tick(ms = 0) { // ─── 13. Entry server rows avoid dynamic HTML strings ────────── { - const app = fs.readFileSync(path.join(__dirname, '..', 'tauri-ui', 'app.js'), 'utf8'); + const app = readAppSource(); const start = app.indexOf('function renderEntryServers()'); const end = app.indexOf('function renderLocalPreview()', start); assert.ok(start > 0 && end > start, 'entry server render section found'); @@ -456,7 +474,7 @@ function tick(ms = 0) { // ─── 14. Preview and proxy status widgets avoid dynamic HTML strings ─ { - const app = fs.readFileSync(path.join(__dirname, '..', 'tauri-ui', 'app.js'), 'utf8'); + const app = readAppSource(); [ ['renderClaudeDesktopPreview', 'let claudeDesktopPreviewFrame'], ['renderLocalPreview', 'claudeProxyBaseUrl'], @@ -479,7 +497,7 @@ function tick(ms = 0) { // ─── 15. Route tables render through DOM helpers ─────────────── { - const app = fs.readFileSync(path.join(__dirname, '..', 'tauri-ui', 'app.js'), 'utf8'); + const app = readAppSource(); assert.ok( /function createRoutesTableElement\([^)]*\)/.test(app), 'route table DOM helper exists' @@ -516,7 +534,7 @@ function tick(ms = 0) { // ─── 16. Rate limit dashboard renders through DOM helpers ────── { - const app = fs.readFileSync(path.join(__dirname, '..', 'tauri-ui', 'app.js'), 'utf8'); + const app = readAppSource(); assert.ok( app.includes('function createLimitsElement(profileId)'), 'rate limit DOM helper exists' @@ -552,7 +570,7 @@ function tick(ms = 0) { // ─── 17. Provider help and profile cards avoid dynamic HTML ──── { - const app = fs.readFileSync(path.join(__dirname, '..', 'tauri-ui', 'app.js'), 'utf8'); + const app = readAppSource(); assert.ok( app.includes('function renderProviderPresetHelp(preset)'), 'provider preset help DOM helper exists' @@ -596,7 +614,7 @@ function tick(ms = 0) { // ─── 18. Trae debug event list avoids dynamic HTML ───────────── { - const app = fs.readFileSync(path.join(__dirname, '..', 'tauri-ui', 'app.js'), 'utf8'); + const app = readAppSource(); const start = app.indexOf('function renderTraeDebugEvents(events)'); const end = app.indexOf('function formatTraeNativeCommandBlock', start); assert.ok(start > 0 && end > start, 'Trae debug event section found'); @@ -613,7 +631,7 @@ function tick(ms = 0) { // ─── 19. Environment tools render through DOM helpers ────────── { - const app = fs.readFileSync(path.join(__dirname, '..', 'tauri-ui', 'app.js'), 'utf8'); + const app = readAppSource(); [ 'createEnvProgressItem(job)', 'createEnvSkeletonCard()', @@ -653,7 +671,7 @@ function tick(ms = 0) { // ─── 20. Certificate status badge avoids dynamic HTML ────────── { - const app = fs.readFileSync(path.join(__dirname, '..', 'tauri-ui', 'app.js'), 'utf8'); + const app = readAppSource(); assert.ok( app.includes('function setStatusDotText(elementId, color, text)'), 'status dot text helper exists' @@ -672,7 +690,7 @@ function tick(ms = 0) { // ─── 21. MCP server table renders through DOM helpers ────────── { - const app = fs.readFileSync(path.join(__dirname, '..', 'tauri-ui', 'app.js'), 'utf8'); + const app = readAppSource(); assert.ok( app.includes('function createMcpServersEmptyElement()'), 'MCP empty DOM helper exists' @@ -705,7 +723,11 @@ function tick(ms = 0) { // ─── 22. Tauri reload menu uses events instead of eval ───────── { - const rust = fs.readFileSync(path.join(__dirname, '..', 'src-tauri', 'src', 'lib.rs'), 'utf8'); + const rust = ['lib.rs', 'launch_on_login.rs'] + .map((file) => + fs.readFileSync(path.join(__dirname, '..', 'src-tauri', 'src', file), 'utf8') + ) + .join('\n'); const bridge = fs.readFileSync(path.join(__dirname, '..', 'tauri-ui', 'app-bridge.js'), 'utf8'); assert.ok(!rust.includes('window.eval'), 'Tauri menu reload should not eval JavaScript'); assert.ok( @@ -728,7 +750,7 @@ function tick(ms = 0) { path.join(__dirname, '..', 'tauri-ui', 'electron-ui.html'), 'utf8' ); - const app = fs.readFileSync(path.join(__dirname, '..', 'tauri-ui', 'app.js'), 'utf8'); + const app = readAppSource(); const retiredModels = [ 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-latest', @@ -841,9 +863,13 @@ function tick(ms = 0) { path.join(__dirname, '..', 'tauri-ui', 'electron-ui.html'), 'utf8' ); - const app = fs.readFileSync(path.join(__dirname, '..', 'tauri-ui', 'app.js'), 'utf8'); + const app = readAppSource(); const bridge = fs.readFileSync(path.join(__dirname, '..', 'tauri-ui', 'app-bridge.js'), 'utf8'); - const rust = fs.readFileSync(path.join(__dirname, '..', 'src-tauri', 'src', 'lib.rs'), 'utf8'); + const rust = ['lib.rs', 'launch_on_login.rs'] + .map((file) => + fs.readFileSync(path.join(__dirname, '..', 'src-tauri', 'src', file), 'utf8') + ) + .join('\n'); const permissions = fs.readFileSync( path.join(__dirname, '..', 'src-tauri', 'permissions', 'app-commands.toml'), 'utf8' @@ -874,7 +900,7 @@ function tick(ms = 0) { // ─── 27. Entry server transport is explicit and round-trips TLS state ─ { - const app = fs.readFileSync(path.join(__dirname, '..', 'tauri-ui', 'app.js'), 'utf8'); + const app = readAppSource(); assert.ok( app.includes("select.dataset.entryTransport = ''"), 'entry rows expose a dedicated HTTP/HTTPS transport selector' diff --git a/test/cert.test.js b/test/cert.test.js index a4a06358..138fa06c 100644 --- a/test/cert.test.js +++ b/test/cert.test.js @@ -35,7 +35,8 @@ try { const jsonLine = result.stdout .trim() .split(/\r?\n/) - .find((line) => line.startsWith('{')); + .find((line) => line.startsWith('{') && line.includes('"commonName"')); + assert.ok(jsonLine, `certificate payload missing from child output: ${result.stdout}`); const payload = JSON.parse(jsonLine); assert.match(payload.commonName, /^HTTPS Proxy Local Root CA [0-9A-F]{10}$/); @@ -84,7 +85,7 @@ try { staleResult.stdout .trim() .split(/\r?\n/) - .find((line) => line.startsWith('{')) + .find((line) => line.startsWith('{') && line.includes('"staleValid"')) ); assert.strictEqual(stalePayload.staleValid, false); assert.strictEqual(stalePayload.repairedValid, true); diff --git a/test/ci-governance.test.js b/test/ci-governance.test.js new file mode 100644 index 00000000..e75a1d3a --- /dev/null +++ b/test/ci-governance.test.js @@ -0,0 +1,46 @@ +'use strict'; + +const assert = require('assert'); +const { + actionReferences, + unpinnedActionReferences, + validateCiGovernance, + validateReleaseWorkflow, +} = require('../lib/ci-governance'); + +const pinned = 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd'; +assert.deepStrictEqual(actionReferences(`- uses: ${pinned} # v6`), [pinned]); +assert.deepStrictEqual(unpinnedActionReferences(`- uses: ${pinned}\n- uses: ./local-action`), []); +assert.deepStrictEqual(unpinnedActionReferences('- uses: actions/checkout@v6'), [ + 'actions/checkout@v6', +]); + +const workflow = [ + `- uses: ${pinned}`, + '- run: npm ci', + '- run: npm audit --audit-level=high', + '- run: npm run test:coverage', + '- run: npm run release:check', + '- run: cargo clippy', + '- run: cargo audit --file src-tauri/Cargo.lock', +].join('\n'); +const dependabot = [ + 'package-ecosystem: npm', + 'package-ecosystem: cargo', + 'package-ecosystem: github-actions', +].join('\n'); +assert.deepStrictEqual(validateCiGovernance(workflow, dependabot), []); +assert.ok(validateCiGovernance('- uses: actions/checkout@v6', '').length >= 7); +const releaseWorkflow = [ + 'WINDOWS_SIGNING_CERTIFICATE_BASE64 is required', + 'Get-AuthenticodeSignature', + 'npm sbom --sbom-format cyclonedx', + 'cargo-cyclonedx --version 0.5.9', + 'smoke-test-nsis.ps1', + 'actions/attest@' + 'a'.repeat(40), + 'gh release create', +].join('\n'); +assert.deepStrictEqual(validateReleaseWorkflow(releaseWorkflow), []); +assert.ok(validateReleaseWorkflow('').length >= 7); + +console.log('[ci-governance.test] ok'); diff --git a/test/core-request-handler.test.js b/test/core-request-handler.test.js index 97acd2b7..c2bd06d6 100644 --- a/test/core-request-handler.test.js +++ b/test/core-request-handler.test.js @@ -1,4 +1,6 @@ const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); const { createCoreRequestHandler } = require('../lib/proxy/core-request-handler'); assert.throws(() => createCoreRequestHandler(), /requires getRuntimeState/); @@ -7,4 +9,12 @@ const handler = createCoreRequestHandler({ getRuntimeState: () => ({}) }); assert.strictEqual(typeof handler, 'function'); assert.strictEqual(handler.name, 'handleRequestInner'); +const source = fs.readFileSync( + path.join(__dirname, '..', 'lib', 'proxy', 'core-request-handler.js'), + 'utf8' +); +assert.ok(source.includes('authorizeManagementRequest(clientReq, MANAGEMENT_TOKEN)')); +assert.ok(!source.includes('isTauriManagementOrigin')); +assert.ok(!source.includes("searchParams.get('token')")); + console.log('[core-request-handler.test] ok'); diff --git a/test/coverage-metrics.test.js b/test/coverage-metrics.test.js new file mode 100644 index 00000000..6f795e53 --- /dev/null +++ b/test/coverage-metrics.test.js @@ -0,0 +1,36 @@ +'use strict'; + +const assert = require('assert'); +const { collectCoverageMetrics, percentage } = require('../lib/coverage-metrics'); + +assert.strictEqual(percentage(0, 0), 100); +assert.strictEqual(percentage(2, 3), 66.66); + +const metrics = collectCoverageMetrics( + { + 'safe.js': { + s: { 0: 1, 1: 0, 2: 2 }, + statementMap: { + 0: { start: { line: 1 } }, + 1: { start: { line: 2 } }, + 2: { start: { line: 1 } }, + }, + f: { 0: 1, 1: 0 }, + b: { 0: [1, 0], 1: [2, 3] }, + }, + 'ignored.js': { + s: { 0: 0 }, + statementMap: { 0: { start: { line: 1 } } }, + f: { 0: 0 }, + b: { 0: [0, 0] }, + }, + }, + (filePath) => filePath === 'safe.js' +); + +assert.deepStrictEqual(metrics.statements, { covered: 2, total: 3, percentage: 66.66 }); +assert.deepStrictEqual(metrics.functions, { covered: 1, total: 2, percentage: 50 }); +assert.deepStrictEqual(metrics.branches, { covered: 3, total: 4, percentage: 75 }); +assert.deepStrictEqual(metrics.lines, { covered: 1, total: 2, percentage: 50 }); + +console.log('[coverage-metrics.test] ok'); diff --git a/test/dev-static-server.test.js b/test/dev-static-server.test.js index 5fb3db0d..563304dd 100644 --- a/test/dev-static-server.test.js +++ b/test/dev-static-server.test.js @@ -32,8 +32,13 @@ assert.strictEqual(parseOptionalPort('bad'), 0); assert.strictEqual(parseOptionalPort('70000'), 0); const tokenGroups = managementTokenCandidateGroups('4543'); +const expectedRuntimeDir = path.resolve( + process.env.PROXY_RUNTIME_DIR || + process.env.PROXY_DATA_DIR || + path.join(path.resolve(__dirname, '..'), '.hpm-runtime') +); assert.ok(tokenGroups[0].some((filePath) => filePath.endsWith('.mgmt-token-4543'))); -assert.ok(tokenGroups[0][0].includes('.hpm-runtime')); +assert.strictEqual(path.dirname(tokenGroups[0][0]), expectedRuntimeDir); assert.ok(tokenGroups[0].some((filePath) => filePath.includes('src-tauri'))); assert.ok(tokenGroups[0].some((filePath) => filePath.includes('HTTPS Proxy Manager Portable'))); assert.ok(tokenGroups[1].some((filePath) => filePath.endsWith('.mgmt-token'))); diff --git a/test/eslint-config.test.js b/test/eslint-config.test.js index 48480e47..9b322438 100644 --- a/test/eslint-config.test.js +++ b/test/eslint-config.test.js @@ -10,4 +10,17 @@ assert.ok( 'generated Tauri target variants must stay outside the lint source boundary' ); +const productionConsoleGate = eslintConfig.find( + (entry) => + Array.isArray(entry.files) && + entry.files.includes('lib/proxy/**/*.js') && + entry.rules?.['no-console'] === 'error' +); +assert.ok(productionConsoleGate, 'production proxy modules must reject direct console usage'); +assert.ok( + productionConsoleGate.ignores.includes('lib/proxy/logger.js') && + productionConsoleGate.ignores.includes('lib/proxy/runtime-support.js'), + 'only the safe logger fallback and runtime console tee may access console directly' +); + console.log('[eslint-config.test] ok'); diff --git a/test/management-auth.test.js b/test/management-auth.test.js new file mode 100644 index 00000000..2a23fd93 --- /dev/null +++ b/test/management-auth.test.js @@ -0,0 +1,45 @@ +'use strict'; + +const assert = require('assert'); +const { + MAX_MANAGEMENT_TOKEN_BYTES, + authorizeManagementRequest, + managementTokenMatches, + readManagementTokenHeader, +} = require('../lib/proxy/management-auth'); + +const token = '0123456789abcdef0123456789abcdef'; + +assert.strictEqual( + authorizeManagementRequest({ headers: { 'x-mgmt-token': token } }, token), + true +); +assert.strictEqual( + authorizeManagementRequest( + { headers: { origin: 'tauri://localhost', 'x-mgmt-token': 'wrong-token' } }, + token + ), + false, + 'a Tauri Origin header must not bypass token authentication' +); +assert.strictEqual( + authorizeManagementRequest( + { headers: { origin: 'tauri://localhost' }, url: `/v1/local/config?token=${token}` }, + token + ), + false, + 'a URL token must not authenticate management requests' +); +assert.strictEqual(authorizeManagementRequest({ headers: {} }, token), false); +assert.strictEqual(authorizeManagementRequest({ headers: { 'x-mgmt-token': [token] } }, token), false); +assert.strictEqual(managementTokenMatches(token, token), true); +assert.strictEqual(managementTokenMatches(token, `${token}0`), false); +assert.strictEqual(managementTokenMatches('', ''), false); +assert.strictEqual( + readManagementTokenHeader({ + headers: { 'x-mgmt-token': 'x'.repeat(MAX_MANAGEMENT_TOKEN_BYTES + 1) }, + }), + '' +); + +console.log('[management-auth.test] ok'); diff --git a/test/management-utils.test.js b/test/management-utils.test.js index e5be83f7..f1c0feea 100644 --- a/test/management-utils.test.js +++ b/test/management-utils.test.js @@ -12,6 +12,7 @@ const { isQuietLocalPollRequest, parseBoundedInteger, parsePortOrDefault, + PRIVATE_FILE_MODE, readJsonBodyFromRequest, readLastLinesOfJsonl, readTextBodyFromResponse, @@ -84,6 +85,9 @@ async function readTextErrorFromChunks(chunks, options) { const textTarget = path.join(tmpDir, 'nested', 'notes.txt'); writeTextFileAtomically(textTarget, 'first'); assert.strictEqual(fs.readFileSync(textTarget, 'utf8'), 'first'); + if (process.platform !== 'win32') { + assert.strictEqual(fs.statSync(textTarget).mode & 0o777, PRIVATE_FILE_MODE); + } writeTextFileAtomically(textTarget, 'second'); assert.strictEqual(fs.readFileSync(textTarget, 'utf8'), 'second'); @@ -93,6 +97,9 @@ async function readTextErrorFromChunks(chunks, options) { ok: true, count: 2, }); + if (process.platform !== 'win32') { + assert.strictEqual(fs.statSync(jsonTarget).mode & 0o777, PRIVATE_FILE_MODE); + } assert.deepStrictEqual( normalizePromptPresets([{ id: ' daily ', name: 'Daily', content: 'hello {{cwd}}' }]), diff --git a/test/mcp-hub.test.js b/test/mcp-hub.test.js index 4fefdf29..03d1b1a6 100644 --- a/test/mcp-hub.test.js +++ b/test/mcp-hub.test.js @@ -26,27 +26,35 @@ async function run() { assert.equal(typeof mcpHubModule.normalizeMcpTool, 'function', 'normalizeMcpTool exported'); assert.equal(typeof mcpHubModule.mergeClaudeTools, 'function', 'mergeClaudeTools exported'); assert.equal( - typeof mcpHubModule._test.compactMcpLogValue, + typeof mcpHubModule._test.buildMcpExecutionRecord, 'function', - 'compactMcpLogValue exported for tests' + 'buildMcpExecutionRecord exported for tests' ); - const compacted = mcpHubModule._test.compactMcpLogValue({ - input: { - apiKey: 'sk-secret', - nested: { - authorization: 'Bearer token', - text: 'x'.repeat(13000), - }, + const metadataRecord = mcpHubModule._test.buildMcpExecutionRecord( + { + serverId: 'docs', + toolName: 'search_docs', + input: { apiKey: 'sk-secret', prompt: 'private user content' }, + result: { authorization: 'Bearer secret', content: 'private result' }, + error: new Error('private failure body'), + elapsed: 17, + status: 'error', }, - }); - assert.equal(compacted.input.apiKey, '[redacted]', 'apiKey redacted in MCP logs'); - assert.equal( - compacted.input.nested.authorization, - '[redacted]', - 'authorization redacted in MCP logs' + new Date('2026-01-01T00:00:00.000Z') ); - assert.ok(compacted.input.nested.text.endsWith('...[truncated]'), 'large MCP log text truncated'); + assert.deepEqual(metadataRecord, { + time: '2026-01-01T00:00:00.000Z', + serverId: 'docs', + toolName: 'search_docs', + status: 'error', + elapsedMs: 17, + inputBytes: 54, + resultBytes: 60, + errorType: 'Error', + }); + assert.ok(!JSON.stringify(metadataRecord).includes('private'), 'MCP logs omit content'); + assert.ok(!JSON.stringify(metadataRecord).includes('secret'), 'MCP logs omit secrets'); assert.equal(mcpHubModule._test.normalizeBearerToken('Bearer glm-key'), 'glm-key'); assert.deepEqual( mcpHubModule._test.mcpRequestHeaders({ bearerToken: 'glm-key' }, '{}').Authorization, @@ -165,6 +173,7 @@ async function run() { }); }); + const warnings = []; const hub = createMcpHub({ getConfig: () => ({ mcpHub: { @@ -184,15 +193,13 @@ async function run() { normalizeMcpTool, mergeClaudeTools, logMcpExecution: () => {}, + logger: { + warn(fields, message) { + warnings.push(`${message} ${fields.attempt}/${fields.maxAttempts}`); + }, + }, }); - // capture warnings to detect retry messages - const warnings = []; - const origWarn = console.warn; - console.warn = (...args) => { - warnings.push(args.join(' ')); - }; - try { const result = await hub.callRemoteMcp( { @@ -209,7 +216,7 @@ async function run() { assert.deepEqual(result, { ok: true, payload: 'success-on-third' }); assert.equal(attempts, 3, 'callRemoteMcp retries until configured maxRetries'); assert.ok( - warnings.some((line) => line.includes('attempt 2/3')), + warnings.some((line) => line.includes('2/3')), 'retry warning includes bounded attempt count' ); @@ -251,7 +258,6 @@ async function run() { // Sanity: hub is callable and has the expected methods assert.equal(typeof hub.resolveToolRoute, 'function', 'hub.resolveToolRoute exists'); } finally { - console.warn = origWarn; server.close(); } @@ -266,32 +272,31 @@ async function run() { }); try { const cappedWarnings = []; - const origWarn = console.warn; - console.warn = (...args) => { - cappedWarnings.push(args.join(' ')); - }; - const cappedHub = createMcpHub({ getConfig: () => ({ mcpHub: { servers: [], routing: [] } }) }); - try { - await assert.rejects( - () => - cappedHub.callRemoteMcp( - { - id: 'bounded', - url: `http://127.0.0.1:${capped.port}/mcp`, - maxRetries: '999', - retryDelayMs: '-100', - timeoutMs: 'Infinity', - }, - 'fail', - {} - ), - /always down/ - ); - assert.equal(cappedAttempts, 11, 'maxRetries is capped to 10 retries plus initial call'); - assert.equal(cappedWarnings.length, 10, 'capped retry path emits one warning per retry'); - } finally { - console.warn = origWarn; - } + const cappedHub = createMcpHub({ + getConfig: () => ({ mcpHub: { servers: [], routing: [] } }), + logger: { + warn(fields, message) { + cappedWarnings.push(`${message} ${fields.attempt}/${fields.maxAttempts}`); + }, + }, + }); + await assert.rejects( + () => + cappedHub.callRemoteMcp( + { + id: 'bounded', + url: `http://127.0.0.1:${capped.port}/mcp`, + maxRetries: '999', + retryDelayMs: '-100', + timeoutMs: 'Infinity', + }, + 'fail', + {} + ), + /always down/ + ); + assert.equal(cappedAttempts, 11, 'maxRetries is capped to 10 retries plus initial call'); + assert.equal(cappedWarnings.length, 10, 'capped retry path emits one warning per retry'); } finally { capped.server.close(); } diff --git a/test/release-artifact-manifest.test.js b/test/release-artifact-manifest.test.js new file mode 100644 index 00000000..ef76f2b2 --- /dev/null +++ b/test/release-artifact-manifest.test.js @@ -0,0 +1,30 @@ +'use strict'; + +const assert = require('assert'); +const { validateArtifactManifest } = require('../lib/release/artifact-manifest'); + +const hash = 'a'.repeat(64); +const files = { 'setup.exe': { bytes: 42, sha256: hash } }; +assert.deepStrictEqual( + validateArtifactManifest([{ file: 'setup.exe', bytes: 42, sha256: hash }], files), + [] +); +assert.ok(validateArtifactManifest([], files)[0].includes('at least one')); +assert.ok( + validateArtifactManifest([{ file: '../setup.exe', bytes: 42, sha256: hash }], files).some( + (error) => error.includes('invalid') + ) +); +assert.ok( + validateArtifactManifest([{ file: 'setup.exe', bytes: 41, sha256: hash }], files).some((error) => + error.includes('size mismatch') + ) +); +assert.ok( + validateArtifactManifest( + [{ file: 'setup.exe', bytes: 42, sha256: 'bad' }], + files + ).some((error) => error.includes('checksum mismatch')) +); + +console.log('[release-artifact-manifest.test] ok'); diff --git a/test/route-response-helpers.test.js b/test/route-response-helpers.test.js new file mode 100644 index 00000000..e368a08b --- /dev/null +++ b/test/route-response-helpers.test.js @@ -0,0 +1,74 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { createRouteResponseHelpers } = require('../lib/proxy/handlers/route-response-helpers'); + +const events = []; +const helpers = createRouteResponseHelpers({ + log: { error() {} }, + logProxyEvent(type, payload) { + events.push({ type, payload }); + }, + logRequestEnd(method, path, startTime, status, meta) { + events.push({ method, path, startTime, status, meta }); + }, + parseGeminiError() { + return { type: 'rate_limit', retryAfterSeconds: 3 }; + }, + sendJsonError() {}, + sendUpstreamError(_res, status, _headers, _body, provider) { + events.push({ status, provider }); + }, + setGeminiCooldown() {}, + writeJsonResponse(_res, status, body) { + events.push({ status, body }); + }, +}); + +assert.deepEqual(helpers.boundedResponseContext({ method: 'POST' }, 10, 'chat'), { + method: 'POST', + startTime: 10, + tooLargeLogType: 'chat_response_too_large', + errorLogType: 'chat_response_error', +}); +assert.deepEqual( + helpers.proxyErrorContext({ method: 'POST', url: '/v1/messages' }, '/upstream', 10, 'x', 'y'), + { + method: 'POST', + path: '/v1/messages', + upstreamPath: '/upstream', + startTime: 10, + type: 'x', + logType: 'y', + logProxyEvent: helpers.proxyErrorContext({ method: 'POST' }, '', 0).logProxyEvent, + logRequestEnd: helpers.proxyErrorContext({ method: 'POST' }, '', 0).logRequestEnd, + extra: undefined, + } +); + +helpers.handleConvertedJsonResponse({ + proxyRes: { statusCode: 200, headers: {} }, + clientReq: { method: 'POST' }, + clientRes: {}, + upstreamPath: '/upstream', + startTime: 10, + responseBody: '{"ok":true}', + successType: 'converted', + convert: JSON.parse, +}); +assert.ok(events.some((event) => event.status === 200 && event.body && event.body.ok)); + +helpers.handleConvertedJsonResponse({ + proxyRes: { statusCode: 503, headers: {} }, + clientReq: { method: 'POST' }, + clientRes: {}, + upstreamPath: '/upstream', + startTime: 10, + responseBody: 'unavailable', + upstreamProvider: 'openai', + upstreamErrorType: 'upstream_error', + convert: JSON.parse, +}); +assert.ok(events.some((event) => event.status === 503 && event.provider === 'openai')); + +console.log('[route-response-helpers.test] ok'); diff --git a/test/runtime-support.test.js b/test/runtime-support.test.js index b7e823ce..3d90d0bb 100644 --- a/test/runtime-support.test.js +++ b/test/runtime-support.test.js @@ -2,23 +2,20 @@ 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'); +const { createRuntimeSupport, 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-')); +fs.writeFileSync(path.join(dataDir, '.mgmt-token-45678'), 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); const support = createRuntimeSupport({ dataDir }); const token = support.writeManagementTokenFile(45678); assert.match(token, /^[0-9a-f]{32}$/); +assert.notStrictEqual(token, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); assert.strictEqual(fs.readFileSync(path.join(dataDir, '.mgmt-token-45678'), 'utf8'), token); assert.strictEqual(support.readExistingManagementToken(45678), token); +assert.strictEqual(support.writeManagementTokenFile(45678), token); console.log('[runtime-support.test] ok'); diff --git a/test/safe-routing-pattern.test.js b/test/safe-routing-pattern.test.js new file mode 100644 index 00000000..4e6fa71d --- /dev/null +++ b/test/safe-routing-pattern.test.js @@ -0,0 +1,25 @@ +'use strict'; + +const assert = require('assert'); +const { + MAX_ROUTING_INPUT_CHARS, + MAX_ROUTING_PATTERN_CHARS, + compileSafeRoutingPattern, + safeRoutingPatternMatches, +} = require('../lib/proxy/safe-routing-pattern'); + +assert.strictEqual(compileSafeRoutingPattern('^glm_search_').ok, true); +assert.strictEqual(safeRoutingPatternMatches('^glm_search_', 'glm_search_web'), true); +assert.strictEqual(safeRoutingPatternMatches('^glm_search_', 'read_file'), false); + +assert.strictEqual(compileSafeRoutingPattern('').ok, false); +assert.strictEqual(compileSafeRoutingPattern('(').ok, false); +assert.strictEqual(compileSafeRoutingPattern('(a+)+$').ok, false); +assert.strictEqual(compileSafeRoutingPattern('(a|aa)+$').ok, false); +assert.strictEqual(compileSafeRoutingPattern('a'.repeat(MAX_ROUTING_PATTERN_CHARS + 1)).ok, false); +assert.strictEqual( + safeRoutingPatternMatches('^a+$', 'a'.repeat(MAX_ROUTING_INPUT_CHARS + 1)), + false +); + +console.log('[safe-routing-pattern.test] ok'); diff --git a/test/server-lifecycle.test.js b/test/server-lifecycle.test.js index 5b71dc7c..d547ccc5 100644 --- a/test/server-lifecycle.test.js +++ b/test/server-lifecycle.test.js @@ -1,6 +1,14 @@ const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); const { startProxyServers } = require('../lib/proxy/server-lifecycle'); assert.throws(() => startProxyServers(), /requires getRuntimeState/); +const source = fs.readFileSync( + path.join(__dirname, '..', 'lib', 'proxy', 'server-lifecycle.js'), + 'utf8' +); +assert.ok(!source.includes('mgmt_token_hint'), 'startup logs must not expose token fragments'); +assert.ok(!source.includes('openai.api_key'), 'startup logs must not print fake or real API keys'); console.log('[server-lifecycle.test] ok'); diff --git a/test/sse-buffer.test.js b/test/sse-buffer.test.js new file mode 100644 index 00000000..cd76a73f --- /dev/null +++ b/test/sse-buffer.test.js @@ -0,0 +1,23 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { createSseBufferAppender, parseBoundedInteger } = require('../lib/proxy/sse-buffer'); + +assert.equal(parseBoundedInteger('128', 64, 1, 256), 128); +assert.equal(parseBoundedInteger('', 64, 1, 256), 64); +assert.equal(parseBoundedInteger('not-a-number', 64, 1, 256), 64); +assert.equal(parseBoundedInteger(0, 64, 1, 256), 1); +assert.equal(parseBoundedInteger(Number.MAX_SAFE_INTEGER, 64, 1, 256), 256); + +assert.throws(() => createSseBufferAppender(0), /positive safe integer/); +assert.throws(() => createSseBufferAppender(Number.MAX_VALUE), /positive safe integer/); + +const append = createSseBufferAppender(5); +const state = {}; +append(state, 'abc', 'Test'); +append(state, 'de', 'Test'); +assert.equal(state.buffer, 'abcde'); +assert.throws(() => append(state, 'f', 'Test'), /Test SSE event too large; max 5 chars/); +assert.equal(state.buffer, '', 'overflow clears retained content before failing'); + +console.log('[sse-buffer.test] ok'); diff --git a/test/tauri-release-wiring.test.js b/test/tauri-release-wiring.test.js index e2ce78f3..dfb33dcc 100644 --- a/test/tauri-release-wiring.test.js +++ b/test/tauri-release-wiring.test.js @@ -6,6 +6,10 @@ const path = require('path'); const repoRoot = path.resolve(__dirname, '..'); const rustLib = fs.readFileSync(path.join(repoRoot, 'src-tauri', 'src', 'lib.rs'), 'utf8'); +const rustCodexLogin = fs.readFileSync( + path.join(repoRoot, 'src-tauri', 'src', 'codex_login.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'), @@ -97,13 +101,28 @@ assert.match( ); const verifyScript = String((packageJson.scripts && packageJson.scripts.verify) || ''); +const rustTestScript = String((packageJson.scripts && packageJson.scripts['test:rust']) || ''); +assert.ok( + verifyScript.includes('npm run test:rust'), + 'the unified verification gate must run the self-contained Rust test gate' +); +assert.ok( + rustTestScript.indexOf('npm run release:prepare') < + rustTestScript.indexOf('cargo test --manifest-path src-tauri/Cargo.toml'), + 'the Rust test gate must prepare release resources before compiling the Tauri context' +); +assert.match( + rustCodexLogin, + /fn launch_codex_login_script\(\) -> Result/, + 'the native login launcher must not accept an executable path from the renderer' +); assert.ok( - verifyScript.indexOf('npm run release:prepare') >= 0, - 'the unified verification gate must prepare Tauri release resources' + rustCodexLogin.includes('CODEX_LOGIN_SCRIPT_FILE_NAME'), + 'the native login launcher must use the fixed managed Codex login script' ); 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' + !rustCodexLogin.includes('"Bypass"'), + 'the native login launcher must not bypass the PowerShell execution policy' ); console.log('[tauri-release-wiring.test] ok'); diff --git a/test/test-runtime.test.js b/test/test-runtime.test.js new file mode 100644 index 00000000..2d8011ff --- /dev/null +++ b/test/test-runtime.test.js @@ -0,0 +1,24 @@ +'use strict'; + +const assert = require('assert'); +const { createIsolatedTestEnv } = require('../lib/test-runtime'); + +const source = { + PATH: 'safe-path', + PROXY_CONFIG_DIR: 'developer-config', + PROXY_RUNTIME_DIR: 'developer-runtime', + PROXY_DATA_DIR: 'developer-data', + NODE_ENV: 'production', +}; +const isolated = createIsolatedTestEnv(source, 'isolated-test-runtime'); + +assert.strictEqual(isolated.PATH, 'safe-path'); +assert.strictEqual(isolated.PROXY_CONFIG_DIR, undefined); +assert.strictEqual(isolated.PROXY_RUNTIME_DIR, undefined); +assert.strictEqual(isolated.PROXY_DATA_DIR, 'isolated-test-runtime'); +assert.strictEqual(isolated.NODE_ENV, 'test'); +assert.strictEqual(source.PROXY_CONFIG_DIR, 'developer-config', 'source environment is not mutated'); +assert.throws(() => createIsolatedTestEnv({}, ''), /runtimeDir/); +assert.throws(() => createIsolatedTestEnv({}, null), /runtimeDir/); + +console.log('[test-runtime.test] ok'); diff --git a/test/validator.test.js b/test/validator.test.js index 96111979..1cd8b7d4 100644 --- a/test/validator.test.js +++ b/test/validator.test.js @@ -12,6 +12,35 @@ const { profiles: [{ id: 'main', protocol: 'openai', targetUrl: 'https://api.example.test/v1' }], localServer: { host: 'localhost', listenPort: 3443, basePath: '/v1' }, entryServers: [{ host: '127.0.0.1', listenPort: 3445, basePath: '/v1' }], + modelRoutes: [ + { + from: 'default', + to: 'gpt-5.6-sol', + profileId: 'main', + clientProtocols: ['openai-chat'], + }, + ], + claudeDesktop: { + enabled: false, + mode: 'proxy', + baseUrl: 'https://localhost:3443', + token: '', + models: [ + { + routeId: 'claude-sonnet-4-6', + upstreamModel: 'gpt-5.6-sol', + profileId: 'main', + supports1m: true, + }, + ], + }, + appSettings: { verifyUpstreamTls: true, conversationAuditEnabled: false }, + protocolOptions: { + thinkingPolicy: 'drop', + contextWindowPolicy: 'overflow-drop-oldest', + maxContextTokens: 0, + maxSseBufferChars: 1024 * 1024, + }, mcpHub: { servers: [{ id: 'filesystem', url: 'http://127.0.0.1:3001' }], routing: [] }, entryAuth: { enabled: true, @@ -48,6 +77,54 @@ const { assert.ok(invalid.errors.some((error) => error.includes('mcpHub.servers'))); } +{ + const invalidBoundaries = validateConfig({ + activeProfile: 'main', + profiles: [{ id: 'main', protocol: 'openai', targetUrl: 'https://api.example.test/v1' }], + localServer: { host: 'localhost', listenPort: 3443, basePath: '/v1' }, + entryServers: {}, + modelRoutes: [ + { from: '', profileId: 'missing', clientProtocols: ['smtp'] }, + 'not-an-object', + ], + claudeDesktop: { + enabled: 'yes', + mode: 'shell', + baseUrl: 'https://user:pass@example.test', + token: 'bad\nsecret', + models: [{ routeId: 42, profileId: 'missing', supports1m: 'yes' }], + }, + appSettings: { verifyUpstreamTls: 'yes' }, + protocolOptions: { + thinkingPolicy: 'execute', + contextWindowPolicy: 'unbounded', + maxContextTokens: Number.POSITIVE_INFINITY, + maxSseBufferChars: 8 * 1024 * 1024, + }, + }); + assert.strictEqual(invalidBoundaries.valid, false); + for (const fragment of [ + 'entryServers must be an array', + 'modelRoutes[0].from is required', + 'profileId does not match any profile', + 'clientProtocols contains unsupported protocol', + 'claudeDesktop.enabled must be a boolean', + 'claudeDesktop.mode is unsupported', + 'must not include credentials', + 'token must not contain line breaks', + 'appSettings.verifyUpstreamTls must be a boolean', + 'protocolOptions.thinkingPolicy is unsupported', + 'protocolOptions.contextWindowPolicy is unsupported', + 'protocolOptions.maxContextTokens', + 'protocolOptions.maxSseBufferChars', + ]) { + assert.ok( + invalidBoundaries.errors.some((error) => error.includes(fragment)), + `missing validation error containing: ${fragment}` + ); + } +} + { const insecureUpstream = validateConfig({ activeProfile: 'main', @@ -112,6 +189,7 @@ const { routing: [ { toolName: 'bad tool', serverId: 'missing' }, { pattern: '(', serverId: 'dup' }, + { pattern: '(a+)+$', serverId: 'dup' }, { serverId: 'dup' }, ], }); @@ -122,6 +200,7 @@ const { assert.ok(invalid.errors.some((error) => error.includes('must be http(s)'))); assert.ok(invalid.errors.some((error) => error.includes('does not match any server'))); assert.ok(invalid.errors.some((error) => error.includes('pattern is invalid'))); + assert.ok(invalid.errors.some((error) => error.includes('unsafe backtracking'))); assert.ok(invalid.errors.some((error) => error.includes('toolName or pattern is required'))); }