Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,8 @@ export class CodexAgentService implements AgentService {
// /proc/<pid>/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[] => {
Expand All @@ -884,6 +886,7 @@ export class CodexAgentService implements AgentService {
'resume',
options.sessionId,
'--json',
...ignoreUserConfigArgs,
...dedup(modelArgs),
...dedup(reasoningArgs),
...dedup(contextWindowArgs),
Expand All @@ -900,6 +903,7 @@ export class CodexAgentService implements AgentService {
: [
'exec',
'--json',
...ignoreUserConfigArgs,
...dedup(modelArgs),
...dedup(reasoningArgs),
...dedup(contextWindowArgs),
Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/utils/cli-resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
30 changes: 30 additions & 0 deletions packages/api/test/cli-resolve.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions packages/api/test/codex-agent-service.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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');
Expand Down
91 changes: 61 additions & 30 deletions scripts/services/prereq-check.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Wrap proxy construction in the probe try/catch

When HTTPS_PROXY/HTTP_PROXY or the registry-derived candidate is malformed, New-Object System.Net.WebProxy($proxyUrl) can throw before the $probe catch block runs; Windows proxy settings commonly use per-scheme strings like http=host:port;https=host:port, which Get-SystemProxyCandidate currently turns into a non-URI. Before this refactor the constructor was inside the try/catch and the source was classified as unreachable, but now one bad proxy value aborts the whole prerequisite check instead of falling back/giving guidance.

Useful? React with 👍 / 👎.

$webProxy.UseDefaultCredentials = $false
$webProxy.Credentials = $null
if (& $probe $webProxy) {
return 'proxy'
} catch {}
}
}
return 'unreachable'
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bypass proxies after direct HF probes

When the HuggingFace artifact probe returns direct, later code can still inject HTTP_PROXY/HTTPS_PROXY because PyPI or Tsinghua needed the proxy, and Invoke-ModelDownloadWithRetry then runs snapshot_download with that global proxy. Without adding huggingface.co (and the mirror direct branch likewise) to NO_PROXY, installs fail on networks where HF downloads work directly but the proxy path returns 407/TLS errors, even though this probe already proved the direct artifact URL works.

Useful? React with 👍 / 👎.

} 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."
}
}

Expand Down
27 changes: 15 additions & 12 deletions scripts/services/prereq-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Probe a real model blob before trusting HF downloads

This gate now declares HuggingFace artifact downloads OK after fetching only config.json, but in this repo that file is just 776 bytes while the actual payloads (model.safetensors / pytorch_model.bin) are 95.8 MB xet files (HF listing). On networks that allow repo metadata/config but block or TLS-fail the xet/CAS/LFS path—the failure mode this change is meant to catch—check_network will still choose the direct/proxy path and the later snapshot_download fails. Please probe a representative model blob, ideally with a ranged request, in both the shell and PowerShell prereq checks.

Useful? React with 👍 / 👎.

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.
Comment on lines 327 to +331

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve direct HF routing in shell downloads

When HTTP_PROXY/HTTPS_PROXY is already set and this probe returns direct, the shell install path now leaves those proxy vars intact. I checked install-template.sh::_install_template_load_model: unless _CATCAFE_HF_PROXY_FOR_DOWNLOAD is set, the Python child simply inherits the environment, so requests/huggingface_hub will still route the model download through the proxy even though the only successful probe was curl --noproxy '*'. This regresses users whose HF or hf-mirror artifact URL works directly but their configured proxy returns 407/TLS failures; the mirror direct branch has the same issue.

Useful? React with 👍 / 👎.

;;
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
;;
Expand Down
27 changes: 27 additions & 0 deletions scripts/start-dev-profile-isolation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,33 @@ 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');
Expand Down
Loading