From 8475eccc3bcda6054d6783a1e31656cde6f1586d Mon Sep 17 00:00:00 2001 From: "CatIw0hr3m3-GPT-5.5" <1097859252@qq.com> Date: Tue, 7 Jul 2026 02:23:42 +0800 Subject: [PATCH 1/5] fix(codex): harden Windows CLI and HF downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: - Windows Codex desktop installs codex.exe outside PATH, so backend resolution must probe the native installer path. - Clowder owns per-invocation Codex config; stale user config.toml values must not break backend exec/resume calls. - HuggingFace homepage probes can pass while model artifact downloads fail; install scripts must probe artifact paths and preserve proxy routing for Python/huggingface_hub downloads. Verification: - pnpm --dir packages/api build - node --test packages/api/test/cli-resolve.test.js - node --import ./packages/api/test/helpers/setup-cat-registry.js --test --test-name-pattern "yields session_init, text, and done on basic success" ./packages/api/test/codex-agent-service.test.js - node --test --test-name-pattern "uses exec resume when sessionId is provided" packages/api/test/codex-agent-service.test.js - node --test --test-name-pattern "probes HuggingFace model artifacts" scripts/start-dev-profile-isolation.test.mjs - bash -n ./scripts/services/prereq-check.sh - PowerShell dot-source scripts/services/prereq-check.ps1 - codex exec resume --ignore-user-config --help [砚砚/gpt-5.5🐾] --- .../agents/providers/CodexAgentService.ts | 4 + packages/api/src/utils/cli-resolve.ts | 3 + packages/api/test/cli-resolve.test.js | 30 ++++++ packages/api/test/codex-agent-service.test.js | 3 + scripts/services/prereq-check.ps1 | 91 +++++++++++++------ scripts/services/prereq-check.sh | 27 +++--- scripts/start-dev-profile-isolation.test.mjs | 18 ++++ 7 files changed, 134 insertions(+), 42 deletions(-) diff --git a/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts index 2bd2673d44..c91b958e3c 100644 --- a/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts @@ -858,6 +858,8 @@ export class CodexAgentService implements AgentService { // /proc//cmdline 会把完整对话历史(含跨 thread/猫/用户内容)暴露给任何 // 并发进程。'--' 结束选项解析,'-' 让 codex 从 stdin 读取 PROMPT。 const promptArgs = ['--', '-']; + // Clowder owns per-invocation config; stale user config.toml must not break backend calls. + const ignoreUserConfigArgs = ['--ignore-user-config']; // Dedup: skip system --config/--flag pairs that the user explicitly overrides (#567). const dedup = (src: string[]): string[] => { @@ -884,6 +886,7 @@ export class CodexAgentService implements AgentService { 'resume', options.sessionId, '--json', + ...ignoreUserConfigArgs, ...dedup(modelArgs), ...dedup(reasoningArgs), ...dedup(contextWindowArgs), @@ -900,6 +903,7 @@ export class CodexAgentService implements AgentService { : [ 'exec', '--json', + ...ignoreUserConfigArgs, ...dedup(modelArgs), ...dedup(reasoningArgs), ...dedup(contextWindowArgs), diff --git a/packages/api/src/utils/cli-resolve.ts b/packages/api/src/utils/cli-resolve.ts index 0440d91888..a8499c5da2 100644 --- a/packages/api/src/utils/cli-resolve.ts +++ b/packages/api/src/utils/cli-resolve.ts @@ -140,6 +140,9 @@ export function resolveCliCommand(command: string, opts?: { skipPathProbe?: bool if (appData) winDirs.push(resolve(appData, 'npm')); if (localAppData) winDirs.push(resolve(localAppData, 'npm')); if (command === 'agy' && localAppData) winDirs.push(resolve(localAppData, 'agy', 'bin')); + // Codex's native Windows desktop app (OpenAI Codex installer) puts codex.exe + // under %LOCALAPPDATA%\OpenAI\Codex\bin, which is never added to PATH. + if (command === 'codex' && localAppData) winDirs.push(resolve(localAppData, 'OpenAI', 'Codex', 'bin')); for (const dir of winDirs) { // Prefer .cmd shim (more reliable for resolveWindowsShimSpawn) const cmdCandidate = resolve(dir, `${command}.cmd`); diff --git a/packages/api/test/cli-resolve.test.js b/packages/api/test/cli-resolve.test.js index 7f7cd58ce2..b8d1ff8ba1 100644 --- a/packages/api/test/cli-resolve.test.js +++ b/packages/api/test/cli-resolve.test.js @@ -200,6 +200,36 @@ test( }, ); +test( + 'resolveCliCommand finds codex in LOCALAPPDATA/OpenAI/Codex/bin on Windows', + { skip: process.platform !== 'win32' && 'Windows-only (Codex native desktop app fallback)' }, + () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'cli-resolve-codex-localappdata-')); + const codexDir = join(tempRoot, 'local', 'OpenAI', 'Codex', 'bin'); + mkdirSync(codexDir, { recursive: true }); + + const fakeCodex = join(codexDir, 'codex.exe'); + writeFileSync(fakeCodex, 'MZ', 'utf8'); + + const originalAppData = process.env.APPDATA; + const originalLocalAppData = process.env.LOCALAPPDATA; + try { + process.env.APPDATA = join(tempRoot, 'roaming'); + process.env.LOCALAPPDATA = join(tempRoot, 'local'); + invalidateCliCommand('codex'); + const result = resolveCliCommand('codex'); + assert.equal(result, fakeCodex, 'should find official Windows Codex native desktop app binary path'); + } finally { + invalidateCliCommand('codex'); + if (originalAppData === undefined) delete process.env.APPDATA; + else process.env.APPDATA = originalAppData; + if (originalLocalAppData === undefined) delete process.env.LOCALAPPDATA; + else process.env.LOCALAPPDATA = originalLocalAppData; + rmSync(tempRoot, { recursive: true, force: true }); + } + }, +); + // --- Unix HOME fallback --- test( diff --git a/packages/api/test/codex-agent-service.test.js b/packages/api/test/codex-agent-service.test.js index e5e8840518..ccd1ca6c80 100644 --- a/packages/api/test/codex-agent-service.test.js +++ b/packages/api/test/codex-agent-service.test.js @@ -264,6 +264,7 @@ describe('CodexAgentService Tests (CLI mode)', { concurrency: false }, () => { ]); const msgs = await promise; + const args = spawnFn.mock.calls[0].arguments[1]; assert.equal(msgs.length, 3); assert.equal(msgs[0].type, 'session_init'); @@ -272,6 +273,7 @@ describe('CodexAgentService Tests (CLI mode)', { concurrency: false }, () => { assert.equal(msgs[1].type, 'text'); assert.equal(msgs[1].content, 'Hello from Codex!'); assert.equal(msgs[2].type, 'done'); + assert.ok(args.includes('--ignore-user-config'), 'codex invocations must ignore stale user config.toml'); }); test('uses exec resume when sessionId is provided', async () => { @@ -299,6 +301,7 @@ describe('CodexAgentService Tests (CLI mode)', { concurrency: false }, () => { // resume 子命令不接受 --sandbox;sandbox mode is replayed through --config. assert.ok(!args.includes('--sandbox'), 'resume args must not include --sandbox'); assert.ok(args.includes('--json'), 'resume args must include --json'); + assert.ok(args.includes('--ignore-user-config'), 'resume args must ignore stale user config.toml'); const modelFlagIndex = args.indexOf('--model'); assert.ok(modelFlagIndex >= 0, 'resume args must include --model'); assert.equal(args[modelFlagIndex + 1], 'gpt-5.3-codex'); diff --git a/scripts/services/prereq-check.ps1 b/scripts/services/prereq-check.ps1 index 7fa2ac6064..61ed99f94b 100644 --- a/scripts/services/prereq-check.ps1 +++ b/scripts/services/prereq-check.ps1 @@ -155,17 +155,44 @@ function Test-SourceMode { # "corp-proxy reaches Tsinghua even though it can't reach pypi" case: # Sync-SystemProxy no longer gates the candidate on a single pypi probe, # so per-source decisions in Assert-Network always see the candidate. - param([string]$Url, [int]$TimeoutSec = 5, [string]$CandidateProxy = $null) + param( + [string]$Url, + [int]$TimeoutSec = 5, + [string]$CandidateProxy = $null, + [ValidateSet("HEAD", "GET")][string]$Method = "HEAD" + ) + + $probe = { + param($Proxy) + $resp = $null + $stream = $null + try { + $req = [System.Net.HttpWebRequest]::Create($Url) + $req.Proxy = $Proxy + $req.Method = $Method + $req.Timeout = $TimeoutSec * 1000 + $req.ReadWriteTimeout = $TimeoutSec * 1000 + $req.AllowAutoRedirect = $true + $req.UserAgent = "cat-cafe-prereq-check" + $resp = $req.GetResponse() + if ($Method -eq "GET") { + $stream = $resp.GetResponseStream() + $buffer = New-Object byte[] 8192 + while ($stream -and $stream.Read($buffer, 0, $buffer.Length) -gt 0) {} + } + return $true + } catch { + return $false + } finally { + if ($stream) { $stream.Dispose() } + if ($resp) { $resp.Close() } + } + } + # 1. Try without any proxy at all. - try { - $req = [System.Net.HttpWebRequest]::Create($Url) - $req.Proxy = $null - $req.Method = 'HEAD' - $req.Timeout = $TimeoutSec * 1000 - $resp = $req.GetResponse() - $resp.Close() + if (& $probe $null) { return 'direct' - } catch {} + } # 2. Try via candidate proxy (anonymous -- DON'T let .NET auto-fill the # SSPI token, that would mask auth-required corp proxies as reachable). $proxyUrl = $CandidateProxy @@ -175,18 +202,12 @@ function Test-SourceMode { if (-not $proxyUrl) { $proxyUrl = $env:HTTP_PROXY } } if ($proxyUrl) { - try { - $webProxy = New-Object System.Net.WebProxy($proxyUrl) - $webProxy.UseDefaultCredentials = $false - $webProxy.Credentials = $null - $req = [System.Net.HttpWebRequest]::Create($Url) - $req.Proxy = $webProxy - $req.Method = 'HEAD' - $req.Timeout = $TimeoutSec * 1000 - $resp = $req.GetResponse() - $resp.Close() + $webProxy = New-Object System.Net.WebProxy($proxyUrl) + $webProxy.UseDefaultCredentials = $false + $webProxy.Credentials = $null + if (& $probe $webProxy) { return 'proxy' - } catch {} + } } return 'unreachable' } @@ -242,6 +263,11 @@ function Invoke-ModelDownloadWithRetry { [string]$Loader = "snapshot" ) + if ($env:OS -eq "Windows_NT") { + $env:HF_HUB_DISABLE_SYMLINKS = if ($env:HF_HUB_DISABLE_SYMLINKS) { $env:HF_HUB_DISABLE_SYMLINKS } else { "1" } + $env:HF_HUB_DISABLE_SYMLINKS_WARNING = if ($env:HF_HUB_DISABLE_SYMLINKS_WARNING) { $env:HF_HUB_DISABLE_SYMLINKS_WARNING } else { "1" } + } + $script = switch ($Loader) { "snapshot" { @" import sys, time, os @@ -455,26 +481,31 @@ function Assert-Network { Write-Host " Auto-set PIP_INDEX_URL = Tsinghua mirror (primary)" } - # Same two-mode probe for HuggingFace. - $hfMode = Test-SourceMode -Url "https://huggingface.co" -TimeoutSec 5 -CandidateProxy $candidate + # Same two-mode probe for HuggingFace. Probe a real model artifact rather + # than the homepage: some networks allow API/root requests but break TLS on + # /resolve artifact downloads, which is the path snapshot_download needs. + $hfProbeUrl = "https://huggingface.co/BAAI/bge-small-zh-v1.5/resolve/main/config.json" + $hfMode = Test-SourceMode -Url $hfProbeUrl -TimeoutSec 10 -CandidateProxy $candidate -Method "GET" if ($hfMode -eq 'direct') { - Write-Host " HuggingFace connectivity [OK] (direct)" - Add-NoProxyHost "huggingface.co" + Write-Host " HuggingFace artifact download [OK] (direct)" + # Do not add huggingface.co to NO_PROXY. The actual download happens in + # Python/huggingface_hub, whose TLS/proxy behavior can differ from this + # .NET probe; preserving the user's proxy avoids false direct bypasses. } elseif ($hfMode -eq 'proxy') { - Write-Host " HuggingFace connectivity [OK] (via proxy: $candidate)" + Write-Host " HuggingFace artifact download [OK] (via proxy: $candidate)" $needProxyInjection = $true } else { - $hfMirrorMode = Test-SourceMode -Url "https://hf-mirror.com" -TimeoutSec 5 -CandidateProxy $candidate + $hfMirrorProbeUrl = "https://hf-mirror.com/BAAI/bge-small-zh-v1.5/resolve/main/config.json" + $hfMirrorMode = Test-SourceMode -Url $hfMirrorProbeUrl -TimeoutSec 10 -CandidateProxy $candidate -Method "GET" if ($hfMirrorMode -eq 'direct') { - Write-Host " HuggingFace unreachable, switching to hf-mirror.com (direct)" + Write-Host " HuggingFace artifact download unreachable, switching to hf-mirror.com (direct)" $env:HF_ENDPOINT = "https://hf-mirror.com" - Add-NoProxyHost "hf-mirror.com" } elseif ($hfMirrorMode -eq 'proxy') { - Write-Host " HuggingFace unreachable, switching to hf-mirror.com (via proxy: $candidate)" + Write-Host " HuggingFace artifact download unreachable, switching to hf-mirror.com (via proxy: $candidate)" $env:HF_ENDPOINT = "https://hf-mirror.com" $needProxyInjection = $true } else { - Write-ProxyGuidance -Context "huggingface.co and hf-mirror.com are unreachable in both direct and via-proxy modes; model download will definitely fail." + Write-ProxyGuidance -Context "huggingface.co and hf-mirror.com artifact downloads are unreachable in both direct and via-proxy modes; model download will definitely fail." } } diff --git a/scripts/services/prereq-check.sh b/scripts/services/prereq-check.sh index 03b7537e46..8ba0e6f33d 100755 --- a/scripts/services/prereq-check.sh +++ b/scripts/services/prereq-check.sh @@ -152,7 +152,7 @@ _test_source_mode() { # in a way that matches pip's runtime path. local url="$1" local timeout="${2:-5}" - if curl -sf --max-time "$timeout" --noproxy '*' "$url" >/dev/null 2>&1; then + if curl -fsL --max-time "$timeout" --noproxy '*' "$url" >/dev/null 2>&1; then echo "direct" return fi @@ -173,7 +173,7 @@ _test_source_mode() { candidate=$(_get_system_proxy_candidate 2>/dev/null || echo "") fi if [ -n "$candidate" ]; then - if curl -sf --max-time "$timeout" -x "$candidate" "$url" >/dev/null 2>&1; then + if curl -fsL --max-time "$timeout" -x "$candidate" "$url" >/dev/null 2>&1; then echo "proxy" return fi @@ -320,36 +320,39 @@ check_network() { echo " Auto-enabled Tsinghua pip mirror as primary source: $PIP_INDEX_URL" fi + local hf_probe_url="https://huggingface.co/BAAI/bge-small-zh-v1.5/resolve/main/config.json" local hf_mode - hf_mode=$(_test_source_mode "https://huggingface.co" "$timeout") + hf_mode=$(_test_source_mode "$hf_probe_url" "$timeout") case "$hf_mode" in direct) - echo " HuggingFace connectivity [OK] (direct)" - _add_no_proxy_host "huggingface.co" + echo " HuggingFace artifact download [OK] (direct)" + # Do not add huggingface.co to NO_PROXY. Python/huggingface_hub can + # behave differently from curl against model CDN/CAS artifact paths, so + # keep any user proxy available for the actual download. ;; proxy) - echo " HuggingFace connectivity [OK] (via env proxy)" + echo " HuggingFace artifact download [OK] (via env proxy)" ;; unreachable) - echo "WARNING: HuggingFace unreachable (https://huggingface.co); model downloads may fail" + echo "WARNING: HuggingFace artifact download unreachable ($hf_probe_url); model downloads may fail" + local hf_mirror_probe_url="https://hf-mirror.com/BAAI/bge-small-zh-v1.5/resolve/main/config.json" local hf_mirror_mode - hf_mirror_mode=$(_test_source_mode "https://hf-mirror.com" "$timeout") + hf_mirror_mode=$(_test_source_mode "$hf_mirror_probe_url" "$timeout") case "$hf_mirror_mode" in direct) if [ -z "${HF_ENDPOINT:-}" ]; then export HF_ENDPOINT="https://hf-mirror.com" - echo " Auto-enabled HF mirror (direct): $HF_ENDPOINT" + echo " Auto-enabled HF mirror for artifact downloads (direct): $HF_ENDPOINT" fi - _add_no_proxy_host "hf-mirror.com" ;; proxy) if [ -z "${HF_ENDPOINT:-}" ]; then export HF_ENDPOINT="https://hf-mirror.com" - echo " Auto-enabled HF mirror (via env proxy): $HF_ENDPOINT" + echo " Auto-enabled HF mirror for artifact downloads (via env proxy): $HF_ENDPOINT" fi ;; unreachable) - _print_proxy_guidance "huggingface.co and hf-mirror.com are unreachable in both direct and via-proxy modes." + _print_proxy_guidance "huggingface.co and hf-mirror.com artifact downloads are unreachable in both direct and via-proxy modes." ;; esac ;; diff --git a/scripts/start-dev-profile-isolation.test.mjs b/scripts/start-dev-profile-isolation.test.mjs index 33cc5f6ca9..8478eac2f5 100644 --- a/scripts/start-dev-profile-isolation.test.mjs +++ b/scripts/start-dev-profile-isolation.test.mjs @@ -718,6 +718,24 @@ describe('TTS sidecar startup guards', () => { }); describe('Whisper sidecar startup guards', () => { + it('probes HuggingFace model artifacts before trusting direct downloads', () => { + const installTemplate = readFileSync(resolve(ROOT, 'scripts/services/prereq-check.sh'), 'utf8'); + const prereqPs1 = readFileSync(resolve(ROOT, 'scripts/services/prereq-check.ps1'), 'utf8'); + + assert.match(installTemplate, /hf_probe_url="https:\/\/huggingface\.co\/BAAI\/bge-small-zh-v1\.5\/resolve\/main\/config\.json"/); + assert.match(installTemplate, /_test_source_mode "\$hf_probe_url"/); + assert.doesNotMatch(installTemplate, /_test_source_mode "https:\/\/huggingface\.co"/); + assert.doesNotMatch(installTemplate, /_add_no_proxy_host "huggingface\.co"/); + assert.doesNotMatch(installTemplate, /_add_no_proxy_host "hf-mirror\.com"/); + + assert.match(prereqPs1, /\$hfProbeUrl = "https:\/\/huggingface\.co\/BAAI\/bge-small-zh-v1\.5\/resolve\/main\/config\.json"/); + assert.match(prereqPs1, /Test-SourceMode -Url \$hfProbeUrl -TimeoutSec 10 -CandidateProxy \$candidate -Method "GET"/); + assert.doesNotMatch(prereqPs1, /Test-SourceMode -Url "https:\/\/huggingface\.co"/); + assert.doesNotMatch(prereqPs1, /Add-NoProxyHost "huggingface\.co"/); + assert.doesNotMatch(prereqPs1, /Add-NoProxyHost "hf-mirror\.com"/); + assert.doesNotMatch(prereqPs1, /_CATCAFE_HF_DOWNLOAD_BYPASS_PROXY/); + }); + it('preloads faster-whisper model artifacts via snapshot_download before runtime load', () => { const installTemplate = readFileSync(resolve(ROOT, 'scripts/services/install-template.sh'), 'utf8'); const prereqPs1 = readFileSync(resolve(ROOT, 'scripts/services/prereq-check.ps1'), 'utf8'); From d0ecdb5d57c026f2287c87ae76703dd38c7f866d Mon Sep 17 00:00:00 2001 From: "CatIw0hr3m3-GPT-5.5" <1097859252@qq.com> Date: Tue, 7 Jul 2026 02:41:43 +0800 Subject: [PATCH 2/5] style(scripts): format HF probe guard test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: CI lint failed because the new HuggingFace artifact guard assertions exceeded Biome's formatting shape. This commit applies the formatter-equivalent line breaks without changing behavior.\n\n[砚砚/gpt-5.5🐾] --- scripts/start-dev-profile-isolation.test.mjs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/scripts/start-dev-profile-isolation.test.mjs b/scripts/start-dev-profile-isolation.test.mjs index 8478eac2f5..dd6126424c 100644 --- a/scripts/start-dev-profile-isolation.test.mjs +++ b/scripts/start-dev-profile-isolation.test.mjs @@ -722,14 +722,23 @@ describe('Whisper sidecar startup guards', () => { const installTemplate = readFileSync(resolve(ROOT, 'scripts/services/prereq-check.sh'), 'utf8'); const prereqPs1 = readFileSync(resolve(ROOT, 'scripts/services/prereq-check.ps1'), 'utf8'); - assert.match(installTemplate, /hf_probe_url="https:\/\/huggingface\.co\/BAAI\/bge-small-zh-v1\.5\/resolve\/main\/config\.json"/); + assert.match( + installTemplate, + /hf_probe_url="https:\/\/huggingface\.co\/BAAI\/bge-small-zh-v1\.5\/resolve\/main\/config\.json"/, + ); assert.match(installTemplate, /_test_source_mode "\$hf_probe_url"/); assert.doesNotMatch(installTemplate, /_test_source_mode "https:\/\/huggingface\.co"/); assert.doesNotMatch(installTemplate, /_add_no_proxy_host "huggingface\.co"/); assert.doesNotMatch(installTemplate, /_add_no_proxy_host "hf-mirror\.com"/); - assert.match(prereqPs1, /\$hfProbeUrl = "https:\/\/huggingface\.co\/BAAI\/bge-small-zh-v1\.5\/resolve\/main\/config\.json"/); - assert.match(prereqPs1, /Test-SourceMode -Url \$hfProbeUrl -TimeoutSec 10 -CandidateProxy \$candidate -Method "GET"/); + assert.match( + prereqPs1, + /\$hfProbeUrl = "https:\/\/huggingface\.co\/BAAI\/bge-small-zh-v1\.5\/resolve\/main\/config\.json"/, + ); + assert.match( + prereqPs1, + /Test-SourceMode -Url \$hfProbeUrl -TimeoutSec 10 -CandidateProxy \$candidate -Method "GET"/, + ); assert.doesNotMatch(prereqPs1, /Test-SourceMode -Url "https:\/\/huggingface\.co"/); assert.doesNotMatch(prereqPs1, /Add-NoProxyHost "huggingface\.co"/); assert.doesNotMatch(prereqPs1, /Add-NoProxyHost "hf-mirror\.com"/); From cff6dd93ceed3241d53e3deca39836d4cb562b7c Mon Sep 17 00:00:00 2001 From: "CatIw0hr3m3-GPT-5.5" <1097859252@qq.com> Date: Tue, 7 Jul 2026 23:25:23 +0800 Subject: [PATCH 3/5] fix: isolate hf model download proxy env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: Windows service installs can probe HuggingFace artifacts as direct-reachable while the Python download child still inherits HTTP_PROXY/HTTPS_PROXY from the API runner. huggingface_hub then follows artifact redirects through the flaky proxy path and fails with SSL EOF. Record the probed HF transport mode and make direct downloads clear proxy env only for the child process, restoring parent env in finally. Keep Windows symlink disabling for non-admin cache writes. Adds a PowerShell regression test that captures the child process environment so the proxy cleanup and symlink guard are verifiable. [砚砚/gpt-5.5🐾] --- .../services/prereq-check.proxy-env.test.ps1 | 82 +++++++++++++++++++ scripts/services/prereq-check.ps1 | 38 +++++++-- 2 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 scripts/services/prereq-check.proxy-env.test.ps1 diff --git a/scripts/services/prereq-check.proxy-env.test.ps1 b/scripts/services/prereq-check.proxy-env.test.ps1 new file mode 100644 index 0000000000..7fba972ee1 --- /dev/null +++ b/scripts/services/prereq-check.proxy-env.test.ps1 @@ -0,0 +1,82 @@ +$ErrorActionPreference = "Stop" + +. "$PSScriptRoot\prereq-check.ps1" + +function Save-EnvVars { + param([string[]]$Names) + $saved = @{} + foreach ($name in $Names) { + $saved[$name] = [Environment]::GetEnvironmentVariable($name, "Process") + } + return $saved +} + +function Restore-EnvVars { + param( + [hashtable]$Saved, + [string[]]$Names + ) + foreach ($name in $Names) { + [Environment]::SetEnvironmentVariable($name, $Saved[$name], "Process") + } +} + +$names = @( + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "HF_HUB_DISABLE_SYMLINKS", + "HF_HUB_DISABLE_SYMLINKS_WARNING" +) +$saved = Save-EnvVars -Names $names +$savedTransportMode = $script:CatCafeHfDownloadTransportMode + +$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("cat-cafe-prereq-test-" + [guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $tempDir -Force | Out-Null +$fakePython = Join-Path $tempDir "python.cmd" +$capture = Join-Path $tempDir "env.txt" + +try { + Set-Content -LiteralPath $fakePython -Encoding ASCII -Value @" +@echo off +( +echo HTTP_PROXY=%HTTP_PROXY% +echo HTTPS_PROXY=%HTTPS_PROXY% +echo ALL_PROXY=%ALL_PROXY% +echo HF_HUB_DISABLE_SYMLINKS=%HF_HUB_DISABLE_SYMLINKS% +echo HF_HUB_DISABLE_SYMLINKS_WARNING=%HF_HUB_DISABLE_SYMLINKS_WARNING% +) > "%CAT_CAFE_TEST_ENV_CAPTURE%" +exit /b 0 +"@ + + $env:CAT_CAFE_TEST_ENV_CAPTURE = $capture + $env:HTTP_PROXY = "http://127.0.0.1:9" + $env:HTTPS_PROXY = "http://127.0.0.1:9" + $env:ALL_PROXY = "http://127.0.0.1:9" + $script:CatCafeHfDownloadTransportMode = "direct" + Remove-Item Env:HF_HUB_DISABLE_SYMLINKS,Env:HF_HUB_DISABLE_SYMLINKS_WARNING -ErrorAction SilentlyContinue + + Invoke-ModelDownloadWithRetry -VenvPython $fakePython -ModelId "dummy/model" -Loader "snapshot" + + $captured = Get-Content -LiteralPath $capture + foreach ($line in @("HTTP_PROXY=", "HTTPS_PROXY=", "ALL_PROXY=")) { + if ($captured -notcontains $line) { + throw "Expected child process proxy env to be cleared; missing '$line'. Captured: $($captured -join '; ')" + } + } + foreach ($line in @("HF_HUB_DISABLE_SYMLINKS=1", "HF_HUB_DISABLE_SYMLINKS_WARNING=1")) { + if ($captured -notcontains $line) { + throw "Expected Windows HuggingFace symlink guard '$line'. Captured: $($captured -join '; ')" + } + } + if ($env:HTTP_PROXY -ne "http://127.0.0.1:9") { + throw "Expected parent HTTP_PROXY to be restored after child invocation." + } + + Write-Host "prereq-check.proxy-env.test.ps1: PASS" +} finally { + Remove-Item Env:CAT_CAFE_TEST_ENV_CAPTURE -ErrorAction SilentlyContinue + $script:CatCafeHfDownloadTransportMode = $savedTransportMode + Restore-EnvVars -Saved $saved -Names $names + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/services/prereq-check.ps1 b/scripts/services/prereq-check.ps1 index 61ed99f94b..b7060b3c68 100644 --- a/scripts/services/prereq-check.ps1 +++ b/scripts/services/prereq-check.ps1 @@ -388,8 +388,34 @@ sys.exit(1) default { throw "Invoke-ModelDownloadWithRetry: unknown loader '$Loader'" } } - & $VenvPython -c $script $ModelId - if ($LASTEXITCODE -ne 0) { throw "Failed to download model: $ModelId" } + $proxyVarNames = if ($env:OS -eq "Windows_NT") { + @("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY") + } else { + @("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy") + } + $savedProxyEnv = @{} + $clearProxyForDownload = ($script:CatCafeHfDownloadTransportMode -eq "direct") + $exitCode = $null + try { + if ($clearProxyForDownload) { + # HuggingFace artifacts can redirect to CDN/CAS hosts that are + # not covered by NO_PROXY. When Assert-Network proved artifact + # downloads work direct, keep this Python child direct-only. + foreach ($name in $proxyVarNames) { + $savedProxyEnv[$name] = [Environment]::GetEnvironmentVariable($name, "Process") + [Environment]::SetEnvironmentVariable($name, $null, "Process") + } + } + & $VenvPython -c $script $ModelId + $exitCode = $LASTEXITCODE + } finally { + if ($clearProxyForDownload) { + foreach ($name in $proxyVarNames) { + [Environment]::SetEnvironmentVariable($name, $savedProxyEnv[$name], "Process") + } + } + } + if ($exitCode -ne 0) { throw "Failed to download model: $ModelId" } } function Assert-Network { @@ -400,6 +426,7 @@ function Assert-Network { # decisions don't depend on whether env was already set. $candidate = Get-SystemProxyCandidate $needProxyInjection = $false + $script:CatCafeHfDownloadTransportMode = $null # FIRST: classify the user's PIP_INDEX_URL (if set) the same way we # classify public mirrors. Internal corporate mirrors typically @@ -488,11 +515,10 @@ function Assert-Network { $hfMode = Test-SourceMode -Url $hfProbeUrl -TimeoutSec 10 -CandidateProxy $candidate -Method "GET" if ($hfMode -eq 'direct') { Write-Host " HuggingFace artifact download [OK] (direct)" - # Do not add huggingface.co to NO_PROXY. The actual download happens in - # Python/huggingface_hub, whose TLS/proxy behavior can differ from this - # .NET probe; preserving the user's proxy avoids false direct bypasses. + $script:CatCafeHfDownloadTransportMode = "direct" } elseif ($hfMode -eq 'proxy') { Write-Host " HuggingFace artifact download [OK] (via proxy: $candidate)" + $script:CatCafeHfDownloadTransportMode = "proxy" $needProxyInjection = $true } else { $hfMirrorProbeUrl = "https://hf-mirror.com/BAAI/bge-small-zh-v1.5/resolve/main/config.json" @@ -500,9 +526,11 @@ function Assert-Network { if ($hfMirrorMode -eq 'direct') { Write-Host " HuggingFace artifact download unreachable, switching to hf-mirror.com (direct)" $env:HF_ENDPOINT = "https://hf-mirror.com" + $script:CatCafeHfDownloadTransportMode = "direct" } elseif ($hfMirrorMode -eq 'proxy') { Write-Host " HuggingFace artifact download unreachable, switching to hf-mirror.com (via proxy: $candidate)" $env:HF_ENDPOINT = "https://hf-mirror.com" + $script:CatCafeHfDownloadTransportMode = "proxy" $needProxyInjection = $true } else { Write-ProxyGuidance -Context "huggingface.co and hf-mirror.com artifact downloads are unreachable in both direct and via-proxy modes; model download will definitely fail." From 796f48732067c3ecbc519befd1cf0e3a6a1bb2c5 Mon Sep 17 00:00:00 2001 From: "CatIw0hr3m3-GPT-5.5" <1097859252@qq.com> Date: Tue, 7 Jul 2026 23:45:36 +0800 Subject: [PATCH 4/5] fix: respect hf endpoint in prereq probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: follow-up review found two Windows prereq edge cases before PR #1097 merged. A malformed Windows proxy candidate such as protocol-specific registry syntax could throw while constructing WebProxy instead of degrading to unreachable, and configured HF_ENDPOINT/HF_HUB_ENDPOINT was not used for the artifact probe that decides whether model downloads should clear proxy env. Wrap proxy construction in the existing source-mode fallback path, probe the same configured HF endpoint that huggingface_hub will use, and keep automatic hf-mirror fallback only for the default endpoint path. Adds regression coverage for malformed proxy candidates and configured HF_ENDPOINT transport-mode decisions. [砚砚/gpt-5.5🐾] --- .../services/prereq-check.proxy-env.test.ps1 | 54 +++++++++++++++++- scripts/services/prereq-check.ps1 | 57 +++++++++++++------ scripts/start-dev-profile-isolation.test.mjs | 5 +- 3 files changed, 96 insertions(+), 20 deletions(-) diff --git a/scripts/services/prereq-check.proxy-env.test.ps1 b/scripts/services/prereq-check.proxy-env.test.ps1 index 7fba972ee1..ece6d46c46 100644 --- a/scripts/services/prereq-check.proxy-env.test.ps1 +++ b/scripts/services/prereq-check.proxy-env.test.ps1 @@ -25,8 +25,14 @@ $names = @( "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", + "NO_PROXY", + "HF_ENDPOINT", + "HF_HUB_ENDPOINT", "HF_HUB_DISABLE_SYMLINKS", - "HF_HUB_DISABLE_SYMLINKS_WARNING" + "HF_HUB_DISABLE_SYMLINKS_WARNING", + "PIP_EXTRA_INDEX_URL", + "PIP_INDEX_URL", + "PIP_TRUSTED_HOST" ) $saved = Save-EnvVars -Names $names $savedTransportMode = $script:CatCafeHfDownloadTransportMode @@ -73,6 +79,52 @@ exit /b 0 throw "Expected parent HTTP_PROXY to be restored after child invocation." } + try { + $mode = Test-SourceMode ` + -Url "https://127.0.0.1:1/" ` + -TimeoutSec 1 ` + -CandidateProxy "http://http=127.0.0.1:7897;https=127.0.0.1:7897" ` + -Method "GET" + } catch { + throw "Expected malformed proxy candidate to be classified as unreachable, not thrown: $($_.Exception.GetType().FullName): $($_.Exception.Message)" + } + if ($mode -ne "unreachable") { + throw "Expected malformed proxy candidate to return unreachable, got '$mode'." + } + + $script:CapturedProbeUrls = @() + function Sync-SystemProxy {} + function Get-SystemProxyCandidate { "http://127.0.0.1:7897" } + function Test-SourceMode { + param( + [string]$Url, + [int]$TimeoutSec = 5, + [string]$CandidateProxy = $null, + [ValidateSet("HEAD", "GET")][string]$Method = "HEAD" + ) + $script:CapturedProbeUrls += $Url + if ($Url -eq "https://internal-hf.example/hub/BAAI/bge-small-zh-v1.5/resolve/main/config.json") { + return "direct" + } + return "unreachable" + } + + $env:HF_ENDPOINT = "https://internal-hf.example/hub" + Remove-Item Env:HF_HUB_ENDPOINT,Env:PIP_EXTRA_INDEX_URL,Env:PIP_INDEX_URL,Env:PIP_TRUSTED_HOST,Env:NO_PROXY -ErrorAction SilentlyContinue + Assert-Network + + $expectedProbeUrl = "https://internal-hf.example/hub/BAAI/bge-small-zh-v1.5/resolve/main/config.json" + if ($script:CapturedProbeUrls -notcontains $expectedProbeUrl) { + throw "Expected Assert-Network to probe configured HF_ENDPOINT '$expectedProbeUrl'. Captured: $($script:CapturedProbeUrls -join '; ')" + } + $defaultProbeUrl = "https://huggingface.co/BAAI/bge-small-zh-v1.5/resolve/main/config.json" + if ($script:CapturedProbeUrls -contains $defaultProbeUrl) { + throw "Expected Assert-Network not to probe default HuggingFace endpoint when HF_ENDPOINT is configured." + } + if ($script:CatCafeHfDownloadTransportMode -ne "direct") { + throw "Expected configured HF_ENDPOINT direct probe to set transport mode to direct, got '$script:CatCafeHfDownloadTransportMode'." + } + Write-Host "prereq-check.proxy-env.test.ps1: PASS" } finally { Remove-Item Env:CAT_CAFE_TEST_ENV_CAPTURE -ErrorAction SilentlyContinue diff --git a/scripts/services/prereq-check.ps1 b/scripts/services/prereq-check.ps1 index b7060b3c68..62e12e303d 100644 --- a/scripts/services/prereq-check.ps1 +++ b/scripts/services/prereq-check.ps1 @@ -202,11 +202,15 @@ function Test-SourceMode { if (-not $proxyUrl) { $proxyUrl = $env:HTTP_PROXY } } if ($proxyUrl) { - $webProxy = New-Object System.Net.WebProxy($proxyUrl) - $webProxy.UseDefaultCredentials = $false - $webProxy.Credentials = $null - if (& $probe $webProxy) { - return 'proxy' + try { + $webProxy = New-Object System.Net.WebProxy($proxyUrl) + $webProxy.UseDefaultCredentials = $false + $webProxy.Credentials = $null + if (& $probe $webProxy) { + return 'proxy' + } + } catch { + return 'unreachable' } } return 'unreachable' @@ -511,7 +515,20 @@ function Assert-Network { # Same two-mode probe for HuggingFace. Probe a real model artifact rather # than the homepage: some networks allow API/root requests but break TLS on # /resolve artifact downloads, which is the path snapshot_download needs. - $hfProbeUrl = "https://huggingface.co/BAAI/bge-small-zh-v1.5/resolve/main/config.json" + # If the user configured a custom HF endpoint, probe that same endpoint; + # otherwise the transport decision can clear a proxy that the actual + # snapshot_download target still needs. + $configuredHfEndpoint = $false + if ($env:HF_ENDPOINT) { + $hfEndpointBase = $env:HF_ENDPOINT.TrimEnd('/') + $configuredHfEndpoint = $true + } elseif ($env:HF_HUB_ENDPOINT) { + $hfEndpointBase = $env:HF_HUB_ENDPOINT.TrimEnd('/') + $configuredHfEndpoint = $true + } else { + $hfEndpointBase = "https://huggingface.co" + } + $hfProbeUrl = "$hfEndpointBase/BAAI/bge-small-zh-v1.5/resolve/main/config.json" $hfMode = Test-SourceMode -Url $hfProbeUrl -TimeoutSec 10 -CandidateProxy $candidate -Method "GET" if ($hfMode -eq 'direct') { Write-Host " HuggingFace artifact download [OK] (direct)" @@ -521,19 +538,23 @@ function Assert-Network { $script:CatCafeHfDownloadTransportMode = "proxy" $needProxyInjection = $true } else { - $hfMirrorProbeUrl = "https://hf-mirror.com/BAAI/bge-small-zh-v1.5/resolve/main/config.json" - $hfMirrorMode = Test-SourceMode -Url $hfMirrorProbeUrl -TimeoutSec 10 -CandidateProxy $candidate -Method "GET" - if ($hfMirrorMode -eq 'direct') { - Write-Host " HuggingFace artifact download unreachable, switching to hf-mirror.com (direct)" - $env:HF_ENDPOINT = "https://hf-mirror.com" - $script:CatCafeHfDownloadTransportMode = "direct" - } elseif ($hfMirrorMode -eq 'proxy') { - Write-Host " HuggingFace artifact download unreachable, switching to hf-mirror.com (via proxy: $candidate)" - $env:HF_ENDPOINT = "https://hf-mirror.com" - $script:CatCafeHfDownloadTransportMode = "proxy" - $needProxyInjection = $true + if ($configuredHfEndpoint) { + Write-ProxyGuidance -Context "configured HuggingFace endpoint artifact downloads are unreachable in both direct and via-proxy modes; model download will definitely fail." } else { - Write-ProxyGuidance -Context "huggingface.co and hf-mirror.com artifact downloads are unreachable in both direct and via-proxy modes; model download will definitely fail." + $hfMirrorProbeUrl = "https://hf-mirror.com/BAAI/bge-small-zh-v1.5/resolve/main/config.json" + $hfMirrorMode = Test-SourceMode -Url $hfMirrorProbeUrl -TimeoutSec 10 -CandidateProxy $candidate -Method "GET" + if ($hfMirrorMode -eq 'direct') { + Write-Host " HuggingFace artifact download unreachable, switching to hf-mirror.com (direct)" + $env:HF_ENDPOINT = "https://hf-mirror.com" + $script:CatCafeHfDownloadTransportMode = "direct" + } elseif ($hfMirrorMode -eq 'proxy') { + Write-Host " HuggingFace artifact download unreachable, switching to hf-mirror.com (via proxy: $candidate)" + $env:HF_ENDPOINT = "https://hf-mirror.com" + $script:CatCafeHfDownloadTransportMode = "proxy" + $needProxyInjection = $true + } else { + Write-ProxyGuidance -Context "huggingface.co and hf-mirror.com artifact downloads are unreachable in both direct and via-proxy modes; model download will definitely fail." + } } } diff --git a/scripts/start-dev-profile-isolation.test.mjs b/scripts/start-dev-profile-isolation.test.mjs index dd6126424c..7b72f37adc 100644 --- a/scripts/start-dev-profile-isolation.test.mjs +++ b/scripts/start-dev-profile-isolation.test.mjs @@ -731,9 +731,12 @@ describe('Whisper sidecar startup guards', () => { assert.doesNotMatch(installTemplate, /_add_no_proxy_host "huggingface\.co"/); assert.doesNotMatch(installTemplate, /_add_no_proxy_host "hf-mirror\.com"/); + assert.match(prereqPs1, /\$env:HF_ENDPOINT/); + assert.match(prereqPs1, /\$env:HF_HUB_ENDPOINT/); + assert.match(prereqPs1, /\$hfEndpointBase = "https:\/\/huggingface\.co"/); assert.match( prereqPs1, - /\$hfProbeUrl = "https:\/\/huggingface\.co\/BAAI\/bge-small-zh-v1\.5\/resolve\/main\/config\.json"/, + /\$hfProbeUrl = "\$hfEndpointBase\/BAAI\/bge-small-zh-v1\.5\/resolve\/main\/config\.json"/, ); assert.match( prereqPs1, From af2989f5b85ad3c894f86ee1d948b35ebe157a3d Mon Sep 17 00:00:00 2001 From: Ragdoll-Sonnet-5 <1097859252@qq.com> Date: Wed, 8 Jul 2026 01:03:56 +0800 Subject: [PATCH 5/5] style: collapse assert.match to one line per biome format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: CI Lint failed on PR #1097 after 796f48732 — biome's formatter collapses this call under the line-width limit, but the commit left it wrapped across multiple lines. No behavioral change; test suite (37/37) still passes. [布偶猫/claude-sonnet-5🐾] --- scripts/start-dev-profile-isolation.test.mjs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/scripts/start-dev-profile-isolation.test.mjs b/scripts/start-dev-profile-isolation.test.mjs index 7b72f37adc..e482244c9e 100644 --- a/scripts/start-dev-profile-isolation.test.mjs +++ b/scripts/start-dev-profile-isolation.test.mjs @@ -734,10 +734,7 @@ describe('Whisper sidecar startup guards', () => { assert.match(prereqPs1, /\$env:HF_ENDPOINT/); assert.match(prereqPs1, /\$env:HF_HUB_ENDPOINT/); assert.match(prereqPs1, /\$hfEndpointBase = "https:\/\/huggingface\.co"/); - assert.match( - prereqPs1, - /\$hfProbeUrl = "\$hfEndpointBase\/BAAI\/bge-small-zh-v1\.5\/resolve\/main\/config\.json"/, - ); + assert.match(prereqPs1, /\$hfProbeUrl = "\$hfEndpointBase\/BAAI\/bge-small-zh-v1\.5\/resolve\/main\/config\.json"/); assert.match( prereqPs1, /Test-SourceMode -Url \$hfProbeUrl -TimeoutSec 10 -CandidateProxy \$candidate -Method "GET"/,