From 8080481302f2366c1694f667f037c0edc9741e0a Mon Sep 17 00:00:00 2001 From: aloneatwar <2318583515@qq.com> Date: Mon, 27 Apr 2026 00:04:03 +0800 Subject: [PATCH 01/16] feat: add Windows temp launch support --- .gitignore | 1 + scripts/windows-start-qa.ps1 | 560 ++++++++++++++++ src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/cli/claude_temp_launch.rs | 634 +++++++++++++++++- src-tauri/src/cli/codex_temp_launch.rs | 620 ++++++++++++++++- src-tauri/src/cli/mod.rs | 17 +- src-tauri/src/cli/orphan_scan.rs | 426 ++++++++++++ src-tauri/src/cli/tui/app.rs | 4 +- src-tauri/src/cli/tui/app/content_entities.rs | 4 +- src-tauri/src/cli/tui/app/tests.rs | 2 + src-tauri/src/cli/tui/ui/providers.rs | 4 +- src-tauri/src/error.rs | 30 + src-tauri/src/main.rs | 5 +- 14 files changed, 2270 insertions(+), 39 deletions(-) create mode 100644 scripts/windows-start-qa.ps1 create mode 100644 src-tauri/src/cli/orphan_scan.rs diff --git a/.gitignore b/.gitignore index ccf947ea..7be12095 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ vitest-report.json skill.md scripts/* !scripts/generate_latest_json.py +!scripts/windows-start-qa.ps1 .agent/ .agents/ diff --git a/scripts/windows-start-qa.ps1 b/scripts/windows-start-qa.ps1 new file mode 100644 index 00000000..9bbc7a5c --- /dev/null +++ b/scripts/windows-start-qa.ps1 @@ -0,0 +1,560 @@ +#Requires -Version 5.1 +#Requires -RunAsAdministrator + +<# +.SYNOPSIS + Windows `cc-switch start` manual QA script — M1-M5 scenarios. + +.DESCRIPTION + Validates the Windows implementation of `cc-switch start` covering: + M1 Normal start + exit for claude + codex (temp cleanup, exit code pass-through) + M2 Parent killed by taskkill /F (Job Object kills child, orphan scan) + M3 Orphaned temp file with dead PID (startup scan cleans it) + M4 Ctrl+C via .cmd shim (no "Terminate batch job (Y/N)?") + M5 Nested Job Object fallback (warning logged, launch continues) + + M1 and M5 both compile a tiny C# stub via csc.exe and prepend its directory + to PATH so cc-switch's `which("claude" / "codex")` resolves to the stub. + This exercises the spawn / Job-Object / cleanup path without requiring a + real CLI installation. + +.PARAMETER CcSwitchPath + Path to cc-switch binary. Defaults to `cc-switch` (PATH lookup). + +.PARAMETER SkipM4 + Skip M4 (requires manual Ctrl+C interaction). + +.PARAMETER SkipM5 + Skip M5 (requires PowerShell Job Object creation). + +.EXAMPLE + .\windows-start-qa.ps1 -CcSwitchPath .\target\release\cc-switch.exe + +.NOTES + Provider selectors default to 'demo'. Override with the env vars + QA_PROVIDER, QA_CLAUDE_PROVIDER, or QA_CODEX_PROVIDER. Set QA_FORCE_STUB + to force the stub path even when a real CLI is installed. +#> + +param( + [string]$CcSwitchPath = "cc-switch", + [switch]$SkipM4, + [switch]$SkipM5 +) + +$ErrorActionPreference = "Stop" +$script:Passed = 0 +$script:Failed = 0 +$script:Skipped = 0 +$script:QaEntryPaths = [System.Collections.Generic.HashSet[string]]::new() + +function Register-QaTempEntry { + param([Parameter(Mandatory)] [string]$Path) + $full = (Resolve-Path $Path -ErrorAction SilentlyContinue).Path + if (-not $full) { $full = $Path } + [void]$script:QaEntryPaths.Add($full) +} + +function Remove-QaTempEntries { + foreach ($p in $script:QaEntryPaths) { + if (Test-Path $p -PathType Container) { + Remove-Item $p -Recurse -Force -ErrorAction SilentlyContinue + } elseif (Test-Path $p) { + Remove-Item $p -Force -ErrorAction SilentlyContinue + } + } + $script:QaEntryPaths.Clear() +} + +# ── Helpers ────────────────────────────────────────────────────────── + +function Write-Section($Title) { + Write-Host "`n========================================" -ForegroundColor Cyan + Write-Host " $Title" -ForegroundColor Cyan + Write-Host "========================================" -ForegroundColor Cyan +} + +function Write-Step($Num, $Desc) { + Write-Host "`n[M$Num] $Desc" -ForegroundColor Yellow +} + +function Record-Pass($Detail = "") { + $script:Passed++ + if ($Detail) { Write-Host " PASS — $Detail" -ForegroundColor Green } + else { Write-Host " PASS" -ForegroundColor Green } +} + +function Record-Fail($Detail) { + $script:Failed++ + Write-Host " FAIL — $Detail" -ForegroundColor Red +} + +function Record-Skip($Reason) { + $script:Skipped++ + Write-Host " SKIP — $Reason" -ForegroundColor DarkGray +} + +function Get-TempDir { + [System.IO.Path]::GetTempPath() +} + +function Find-CcSwitchTempEntries { + $temp = Get-TempDir + $claude = Get-ChildItem $temp -Name "cc-switch-claude-*" -ErrorAction SilentlyContinue + $codex = Get-ChildItem $temp -Name "cc-switch-codex-*" -ErrorAction SilentlyContinue + @($claude) + @($codex) | Where-Object { $_ } +} + +function Find-CcSwitchQaTempEntries { + # Only match entries with '-qa-' in the name (M3 fake orphans or QA stubs). + # Normal cc-switch start entries are left for orphan_scan to handle, + # so we don't interfere with concurrently running real sessions. + $temp = Get-TempDir + $claude = Get-ChildItem $temp -Name "cc-switch-claude-*-qa-*" -ErrorAction SilentlyContinue + $codex = Get-ChildItem $temp -Name "cc-switch-codex-*-qa-*" -ErrorAction SilentlyContinue + @($claude) + @($codex) | Where-Object { $_ } +} + +function Remove-AllCcSwitchTempEntries { + Find-CcSwitchQaTempEntries | ForEach-Object { + $p = Join-Path (Get-TempDir) $_ + if (Test-Path $p -PathType Container) { + Remove-Item $p -Recurse -Force -ErrorAction SilentlyContinue + } else { + Remove-Item $p -Force -ErrorAction SilentlyContinue + } + } +} + +function Get-ExePath { + $raw = & where.exe $CcSwitchPath 2>$null + if ($LASTEXITCODE -eq 0 -and $raw) { return $raw.Trim() } + if (Test-Path $CcSwitchPath) { return (Resolve-Path $CcSwitchPath).Path } + return $null +} + +function Build-StubExe { + <# + .SYNOPSIS + Compile a tiny C# stub binary to act as a stand-in for claude/codex CLI. + The stub sleeps briefly and exits with the requested code, so cc-switch's + spawn / Job-Object path runs end-to-end without a real CLI installed. + .OUTPUTS + Path to the directory containing the stub on success, $null on failure. + The file is always named `.exe` so `which("")` resolves to it + when the directory is on PATH. + #> + param( + [Parameter(Mandatory)] [string]$Tool, + [int]$ExitCode = 42, + [int]$SleepMs = 800, + [string]$Suffix = "" + ) + + $name = if ($Suffix) { "cc-switch-qa-stub-$Tool-$Suffix" } else { "cc-switch-qa-stub-$Tool" } + $dir = Join-Path (Get-TempDir) $name + $stub = Join-Path $dir "$Tool.exe" + + if (Test-Path $stub) { + return $dir + } + + New-Item -ItemType Directory -Path $dir -Force | Out-Null + $cs = @" +using System; using System.Threading; +class Program { static int Main() { Thread.Sleep($SleepMs); return $ExitCode; } } +"@ + $csPath = Join-Path $dir "stub.cs" + Set-Content -Path $csPath -Value $cs + & csc.exe /nologo /out:"$stub" "$csPath" 2>$null + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $stub)) { + return $null + } + return $dir +} + +function Test-StubLaunch { + <# + .SYNOPSIS + Run `cc-switch start ` against a freshly compiled stub + that exits with $ExpectedExitCode, then verify exit-code propagation + and that the per-launch temp entry is cleaned up. + .NOTES + On Windows cc-switch wraps non-zero child exits as Err and the wrapper + process exits with 1, but the original code is included in the error + message. We accept either signal as evidence of pass-through. + Set $env:QA_FORCE_STUB to force the stub path even when a real CLI is + installed. + #> + param( + [Parameter(Mandatory)] [string]$Tool, + [Parameter(Mandatory)] [string]$ProviderId, + [int]$ExpectedExitCode = 42 + ) + + $existing = & where.exe $Tool 2>$null + if ($LASTEXITCODE -eq 0 -and -not $env:QA_FORCE_STUB) { + Write-Host "`n Real $Tool CLI found at: $existing" + Write-Host " This is a MANUAL step. Execute the following in a separate terminal:" + Write-Host " $exe start $Tool $ProviderId" + Write-Host " Then exit normally. Expected: temp entry cleaned, exit code = 0." + Write-Host " (Set `$env:QA_FORCE_STUB=1 to bypass real-CLI detection.)" + Record-Skip "Requires manual interaction with real $Tool CLI" + return + } + + Write-Host "`n $Tool CLI not found in PATH (or stub forced). Using compiled stub." -ForegroundColor DarkYellow + + $stubDir = Build-StubExe -Tool $Tool -ExitCode $ExpectedExitCode + if (-not $stubDir) { + Write-Host " Could not compile stub; ensure csc.exe is available." -ForegroundColor DarkYellow + Record-Skip "$Tool CLI not available and stub compilation failed" + return + } + + $oldPath = $env:PATH + $env:PATH = "$stubDir;$oldPath" + try { + Remove-QaTempEntries + Write-Host "`n Running: cc-switch start $Tool $ProviderId" + $output = & $exe start $Tool $ProviderId 2>&1 + $exitCode = $LASTEXITCODE + $after = Find-CcSwitchTempEntries + $outputStr = ($output | Out-String).Trim() + + Write-Host " Exit code: $exitCode" + + # Detect provider-misconfiguration up front so we don't report a + # spurious failure for an environment that simply lacks a $Tool provider. + if ($outputStr -match "did not match any|未匹配到任何") { + Record-Skip "$Tool provider '$ProviderId' is not configured (start aborted before stub ran)" + return + } + + $passThrough = + ($exitCode -eq $ExpectedExitCode) -or + ($outputStr -match "exited with code\s+$ExpectedExitCode") -or + ($outputStr -match "退出码非零:\s*$ExpectedExitCode") + if ($passThrough) { + Record-Pass "$Tool stub exit code $ExpectedExitCode propagated (cc-switch exit=$exitCode)" + } else { + Record-Fail "Expected exit code $ExpectedExitCode in output for $Tool, got exit $exitCode; output: $outputStr" + } + + if (($after | Measure-Object).Count -eq 0) { + Record-Pass "No orphaned temp entries after $Tool exit" + } else { + Record-Fail "Orphaned entries remain after $Tool exit: $($after -join ', ')" + foreach ($entry in $after) { + Register-QaTempEntry -Path (Join-Path (Get-TempDir) $entry) + } + } + } finally { + $env:PATH = $oldPath + } +} + +# ── Prerequisites ──────────────────────────────────────────────────── + +Write-Section "Prerequisites" + +$exe = Get-ExePath +if (-not $exe) { + Write-Host "ERROR: Cannot find cc-switch binary: $CcSwitchPath" -ForegroundColor Red + exit 1 +} +Write-Host "cc-switch binary: $exe" + +$tempDir = Get-TempDir +Write-Host "Temp directory: $tempDir" + +# Clean any leftover entries from previous runs +Remove-AllCcSwitchTempEntries +Write-Host "Cleared leftover cc-switch temp entries." + +# ── M1: Normal start + exit ──────────────────────────────────────── + +Write-Section "M1 — Normal start + exit (claude + codex)" +Write-Step 1 "Run cc-switch start against stub, verify temp cleanup + exit-code pass-through" + +Write-Host "`n NOTE: M1 requires a configured provider for each app under test." +Write-Host " Defaults to provider id 'demo'. Override via env vars:" +Write-Host " `$env:QA_PROVIDER (used by both apps)" +Write-Host " `$env:QA_CLAUDE_PROVIDER (claude-only override)" +Write-Host " `$env:QA_CODEX_PROVIDER (codex-only override)" + +$provider = if ($env:QA_PROVIDER) { $env:QA_PROVIDER } else { "demo" } +$claudeProvider = if ($env:QA_CLAUDE_PROVIDER) { $env:QA_CLAUDE_PROVIDER } else { $provider } +$codexProvider = if ($env:QA_CODEX_PROVIDER) { $env:QA_CODEX_PROVIDER } else { $provider } + +Write-Host " claude provider: $claudeProvider" +Write-Host " codex provider: $codexProvider" + +# Each Test-StubLaunch call compiles a `.exe` stub into its own subdir +# and prepends it to PATH, so `which::which("")` inside cc-switch +# resolves to the stub. This exercises the spawn / Job-Object / cleanup path +# end-to-end without requiring the real CLI. +Test-StubLaunch -Tool "claude" -ProviderId $claudeProvider -ExpectedExitCode 42 +Test-StubLaunch -Tool "codex" -ProviderId $codexProvider -ExpectedExitCode 42 + +# ── M2: taskkill /F parent ───────────────────────────────────────── + +Write-Section "M2 — taskkill /F parent" +Write-Step 2 "Kill parent cc-switch, verify Job Object kills child + orphan scan" + +$claudeExe = & where.exe claude 2>$null +if ($LASTEXITCODE -ne 0) { + Record-Skip "Claude CLI not in PATH; skipping M2" +} else { + Write-Host "`n This test will:" + Write-Host " 1. Start cc-switch start claude $claudeProvider (background)" + Write-Host " 2. Wait for temp file to appear" + Write-Host " 3. taskkill /F the cc-switch parent process" + Write-Host " 4. Verify the child (claude) process is also terminated" + Write-Host " 5. Run cc-switch env tools to trigger orphan scan" + Write-Host " 6. Verify temp file is gone" + + Remove-QaTempEntries + + # Start cc-switch in a new window so we can observe it + $proc = Start-Process -FilePath $exe -ArgumentList @("start","claude",$claudeProvider) ` + -PassThru -WindowStyle Hidden + + Write-Host "`n Started cc-switch PID $($proc.Id), waiting for temp file..." + $tempEntry = $null + for ($i = 0; $i -lt 30; $i++) { + Start-Sleep -Milliseconds 200 + $entries = Find-CcSwitchTempEntries + if ($entries) { + $tempEntry = $entries[0] + Register-QaTempEntry -Path (Join-Path (Get-TempDir) $tempEntry) + break + } + } + + if (-not $tempEntry) { + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + Record-Fail "Temp file did not appear within 6 seconds" + } else { + Write-Host " Temp entry appeared: $tempEntry" + + # Kill the parent + taskkill /F /PID $proc.Id 2>$null | Out-Null + Start-Sleep -Seconds 1 + + # Check if child claude processes are still alive + $claudeProcs = Get-Process -Name "claude" -ErrorAction SilentlyContinue + if ($claudeProcs) { + Record-Fail "Claude child process(es) still alive after parent taskkill" + $claudeProcs | Stop-Process -Force -ErrorAction SilentlyContinue + } else { + Record-Pass "Claude child process terminated along with parent (Job Object)" + } + + # Trigger orphan scan by running another cc-switch command + & $exe env tools 2>&1 | Out-Null + Start-Sleep -Milliseconds 500 + + $remaining = Find-CcSwitchTempEntries + if ($remaining -contains $tempEntry) { + Record-Fail "Orphan scan did not clean temp entry: $tempEntry" + Register-QaTempEntry -Path (Join-Path (Get-TempDir) $tempEntry) + } else { + Record-Pass "Orphan scan cleaned temp entry after parent death" + } + } +} + +# ── M3: Orphaned file with dead PID ──────────────────────────────── + +Write-Section "M3 — Orphaned file with dead PID" +Write-Step 3 "Create fake orphan file with non-existent PID, verify scan cleans it" + +Remove-QaTempEntries + +# Use a PID that is extremely unlikely to exist (max PID on Windows is ~2^24) +$deadPid = 999999 +$oldNanos = [DateTimeOffset]::UtcNow.AddHours(-25).ToUnixTimeMilliseconds() * 1000000L + +# Create a fake orphaned settings file +$orphanFile = Join-Path $tempDir "cc-switch-claude-qa-$deadPid-$oldNanos.json" +Set-Content -Path $orphanFile -Value '{"env":{"ANTHROPIC_AUTH_TOKEN":"fake"}}' +Write-Host " Created fake orphan: $orphanFile" +Register-QaTempEntry -Path $orphanFile + +# Also create a fake orphaned codex home dir +$orphanDir = Join-Path $tempDir "cc-switch-codex-qa-$deadPid-$oldNanos" +New-Item -ItemType Directory -Path $orphanDir -Force | Out-Null +Set-Content -Path (Join-Path $orphanDir "config.toml") -Value "model = \"test\"" +Write-Host " Created fake orphan dir: $orphanDir" +Register-QaTempEntry -Path $orphanDir + +# Trigger orphan scan by running any cc-switch command +& $exe env tools 2>&1 | Out-Null +Start-Sleep -Milliseconds 500 + +$missingFile = -not (Test-Path $orphanFile) +$missingDir = -not (Test-Path $orphanDir) + +if ($missingFile) { + Record-Pass "Orphan file with dead PID cleaned" +} else { + Record-Fail "Orphan file still exists: $orphanFile" +} + +if ($missingDir) { + Record-Pass "Orphan dir with dead PID cleaned" +} else { + Record-Fail "Orphan dir still exists: $orphanDir" +} + +# ── M4: Ctrl+C via .cmd shim ─────────────────────────────────────── + +Write-Section "M4 — Ctrl+C via .cmd shim" +Write-Step 4 "Verify Ctrl+C is handled by Claude, not cmd.exe" + +if ($SkipM4) { + Record-Skip "Skipped by -SkipM4 flag" +} else { + # Check if claude is a .cmd/.bat shim + $claudeWhich = & where.exe claude 2>$null + if ($LASTEXITCODE -ne 0) { + Record-Skip "Claude CLI not in PATH" + } else { + $isCmdShim = $claudeWhich -match '\.(cmd|bat)$' + if (-not $isCmdShim) { + Write-Host " Claude resolves to: $claudeWhich" + Write-Host " Not a .cmd/.bat shim. M4 is N/A for your installation." -ForegroundColor DarkYellow + Record-Skip "Claude is not installed via .cmd/.bat shim" + } else { + Write-Host " Claude shim detected: $claudeWhich" + Write-Host "`n MANUAL STEP REQUIRED:" + Write-Host " 1. Open a NEW terminal (conhost or Windows Terminal)" + Write-Host " 2. Run: $exe start claude $claudeProvider" + Write-Host " 3. Wait for Claude TUI to appear" + Write-Host " 4. Press Ctrl+C" + Write-Host " Expected behavior:" + Write-Host " - Claude TUI handles Ctrl+C (e.g. cancels current input)" + Write-Host " - NO 'Terminate batch job (Y/N)?' prompt from cmd.exe" + Write-Host " - If prompt appears, this is a FAIL" + Write-Host "`n After testing, press Enter in this window to continue..." + [void][Console]::ReadLine() + Record-Skip "Requires manual verification result (not auto-detected)" + } + } +} + +# ── M5: Nested Job Object fallback ───────────────────────────────── + +Write-Section "M5 — Nested Job Object fallback" +Write-Step 5 "Launch cc-switch inside a PowerShell Job Object, verify fallback" + +if ($SkipM5) { + Record-Skip "Skipped by -SkipM5 flag" +} else { + # PowerShell itself may already be in a Job Object on some configurations. + # We explicitly create a new Job Object and run cc-switch inside it. + $pinvoke = @" +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +public class JobHelper { + [DllImport("kernel32.dll")] + public static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName); + + [DllImport("kernel32.dll")] + public static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess); + + [DllImport("kernel32.dll")] + public static extern bool CloseHandle(IntPtr hObject); + + [DllImport("kernel32.dll")] + public static extern IntPtr GetCurrentProcess(); +} +"@ + Add-Type -TypeDefinition $pinvoke -Language CSharp -ErrorAction SilentlyContinue + + $jobName = "cc-switch-qa-nested-job-" + [Guid]::NewGuid().ToString("N") + $hJob = [JobHelper]::CreateJobObject([IntPtr]::Zero, $jobName) + if ($hJob -eq [IntPtr]::Zero -or $hJob -eq [IntPtr] -1) { + Record-Skip "Could not create Job Object (error: $([Marshal]::GetLastWin32Error()))" + } else { + $self = [JobHelper]::GetCurrentProcess() + $assigned = [JobHelper]::AssignProcessToJobObject($hJob, $self) + if (-not $assigned) { + # Already in a job — this is actually the common case on Windows Terminal / some shells + Write-Host " Current process is already in a Job Object (AssignProcessToJobObject failed with $($Error[0]))." + Write-Host " This is expected on Windows Terminal and some shells." + } else { + Write-Host " Assigned current process to new Job Object." + } + + # The AssignProcessToJobObject fallback only runs in the spawn path of + # `cc-switch start ` (claude_temp_launch.rs / codex_temp_launch.rs). + # `env tools` never spawns a child, so it would not exercise that code. + # Compile a claude.exe stub that exits 0, prepend it to PATH, then run + # `cc-switch start claude --verbose` to drive the actual fallback path. + $m5StubDir = Build-StubExe -Tool "claude" -ExitCode 0 -SleepMs 400 -Suffix "m5" + if (-not $m5StubDir) { + Write-Host " Could not compile claude stub for M5; ensure csc.exe is available." -ForegroundColor DarkYellow + Record-Skip "Claude stub for M5 unavailable (csc.exe required)" + } else { + $m5OldPath = $env:PATH + $env:PATH = "$m5StubDir;$m5OldPath" + try { + Remove-QaTempEntries + Write-Host "`n Running: $exe start claude $claudeProvider --verbose" + $output = & $exe start claude $claudeProvider --verbose 2>&1 + $startExit = $LASTEXITCODE + $outputStr = ($output | Out-String).Trim() + $m5After = Find-CcSwitchTempEntries + + if ($outputStr -match "did not match any|未匹配到任何") { + Record-Skip "claude provider '$claudeProvider' is not configured (start aborted before fallback path)" + } else { + # WARN-level logs only surface with --verbose (debug filter). + # Both the claude target ('windows.job_assign_failed_fallback') + # and the codex localized message ("falling back" / "降级回退") + # contain at least one of these tokens. + if ($outputStr -match "job_assign_failed_fallback|AssignProcessToJobObject|falling back|降级回退|fallback") { + Record-Pass "Nested Job Object fallback warning detected in start claude output" + } else { + Write-Host " Output did not contain expected fallback warning." -ForegroundColor DarkYellow + Write-Host " (May indicate this shell is NOT actually nested inside a Job Object," -ForegroundColor DarkYellow + Write-Host " or that logs are routed somewhere other than stderr.)" -ForegroundColor DarkYellow + Record-Skip "Fallback warning not visible in stdout/stderr; verify shell is in a Job Object" + } + + if ($startExit -eq 0) { + Record-Pass "start claude succeeded despite nested Job Object (fallback path works)" + } else { + Record-Fail "start claude failed with exit code $startExit despite stub returning 0; output: $outputStr" + } + + foreach ($entry in $m5After) { + Register-QaTempEntry -Path (Join-Path (Get-TempDir) $entry) + } + } + } finally { + $env:PATH = $m5OldPath + } + } + + [JobHelper]::CloseHandle($hJob) | Out-Null + } +} + +# ── Summary ────────────────────────────────────────────────────────── + +Write-Section "Summary" +Write-Host "Passed: $script:Passed" +Write-Host "Failed: $script:Failed" +Write-Host "Skipped: $script:Skipped" + +if ($script:Failed -gt 0) { + Write-Host "`nRESULT: FAILED ($script:Failed scenario(s) failed)" -ForegroundColor Red + exit 1 +} else { + Write-Host "`nRESULT: PASSED (all checked scenarios passed)" -ForegroundColor Green + exit 0 +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b8d1253e..58ea908c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -522,6 +522,7 @@ dependencies = [ "url", "uuid", "which 6.0.3", + "windows-sys 0.59.0", "winreg", "zip", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7dc27ffe..39a7dab9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -90,6 +90,7 @@ tachyonfx = "0.25" [target.'cfg(target_os = "windows")'.dependencies] winreg = "0.52" self-replace = "1" +windows-sys = { version = "0.59", features = ["Win32_System_JobObjects", "Win32_System_Console", "Win32_System_Threading", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization"] } # Optimize release binary size to help reduce AppImage footprint [profile.release] diff --git a/src-tauri/src/cli/claude_temp_launch.rs b/src-tauri/src/cli/claude_temp_launch.rs index 6085afcc..f9f22b1b 100644 --- a/src-tauri/src/cli/claude_temp_launch.rs +++ b/src-tauri/src/cli/claude_temp_launch.rs @@ -4,6 +4,9 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt; + use crate::error::AppError; use crate::provider::Provider; use crate::services::provider::ProviderService; @@ -96,14 +99,9 @@ pub(crate) fn ensure_temp_launch_supported() -> Result<(), AppError> { Ok(()) } -#[cfg(not(unix))] +#[cfg(windows)] pub(crate) fn ensure_temp_launch_supported() -> Result<(), AppError> { - Err(AppError::localized( - "claude.temp_launch_unsupported_platform", - "当前平台暂不支持在当前终端临时启动 Claude。".to_string(), - "Temporary Claude launch in the current terminal is not supported on this platform." - .to_string(), - )) + Ok(()) } #[cfg(unix)] @@ -137,17 +135,451 @@ pub(crate) fn exec_prepared_claude( )) } -#[cfg(not(unix))] +#[cfg(windows)] +pub(crate) fn build_command_windows( + prepared: &PreparedClaudeLaunch, + native_args: &[OsString], +) -> std::process::Command { + let exe_str = prepared.executable.to_string_lossy(); + let is_cmd = exe_str.ends_with(".cmd") || exe_str.ends_with(".bat"); + + if is_cmd { + let mut cmd = std::process::Command::new("cmd.exe"); + cmd.arg("/c"); + cmd.arg(&prepared.executable); + cmd.arg("--settings"); + cmd.arg(&prepared.settings_path); + cmd.args(native_args); + cmd + } else { + let mut cmd = std::process::Command::new(&prepared.executable); + cmd.arg("--settings"); + cmd.arg(&prepared.settings_path); + cmd.args(native_args); + cmd + } +} + +#[cfg(windows)] +struct ScopedConsoleCtrlHandler; + +#[cfg(windows)] +impl ScopedConsoleCtrlHandler { + fn install() -> Result { + unsafe { + let result = windows_sys::Win32::System::Console::SetConsoleCtrlHandler( + Some(ctrl_handler_swallow), + 1, + ); + if result == 0 { + return Err(AppError::localized( + "windows.console_ctrl_handler_failed", + "安装控制台 Ctrl+C 处理器失败".to_string(), + "Failed to install console Ctrl+C handler.".to_string(), + )); + } + } + Ok(ScopedConsoleCtrlHandler) + } +} + +#[cfg(windows)] +impl Drop for ScopedConsoleCtrlHandler { + fn drop(&mut self) { + unsafe { + let _ = windows_sys::Win32::System::Console::SetConsoleCtrlHandler( + Some(ctrl_handler_swallow), + 0, + ); + } + } +} + +#[cfg(windows)] +unsafe extern "system" fn ctrl_handler_swallow(_dw_ctrl_type: u32) -> i32 { + 1 +} + +#[cfg(windows)] +struct Job { + handle: windows_sys::Win32::Foundation::HANDLE, +} + +#[cfg(windows)] +impl Job { + unsafe fn create_with_kill_on_close() -> Result { + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::JobObjects::{ + CreateJobObjectW, JobObjectExtendedLimitInformation, SetInformationJobObject, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }; + + let handle = CreateJobObjectW(std::ptr::null(), std::ptr::null()); + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + return Err(AppError::localized( + "windows.create_job_object_failed", + "创建 Job Object 失败".to_string(), + "Failed to create Job Object.".to_string(), + )); + } + + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + + let result = SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + &info as *const _ as *const _, + std::mem::size_of::() as u32, + ); + + if result == 0 { + CloseHandle(handle); + return Err(AppError::localized( + "windows.set_job_info_failed", + "设置 Job Object 信息失败".to_string(), + "Failed to set Job Object information.".to_string(), + )); + } + + Ok(Job { handle }) + } + + unsafe fn try_assign( + &self, + process: windows_sys::Win32::Foundation::HANDLE, + ) -> Result<(), std::io::Error> { + use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject; + let result = AssignProcessToJobObject(self.handle, process); + if result == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + } + + unsafe fn terminate(&self) { + use windows_sys::Win32::System::JobObjects::TerminateJobObject; + let _ = TerminateJobObject(self.handle, 1); + } +} + +#[cfg(windows)] +impl Drop for Job { + fn drop(&mut self) { + use windows_sys::Win32::Foundation::CloseHandle; + unsafe { + let _ = CloseHandle(self.handle); + } + } +} + +#[cfg(windows)] +fn build_windows_cmdline( + prepared: &PreparedClaudeLaunch, + native_args: &[OsString], +) -> Result, AppError> { + let exe_str = prepared.executable.to_string_lossy(); + let is_cmd = exe_str.ends_with(".cmd") || exe_str.ends_with(".bat"); + + let mut cmdline = String::new(); + + if is_cmd { + // cmd.exe expands %VAR% and !VAR! (delayed expansion) even inside + // double quotes. There is no standard escape for these in a /c + // command line. We quote the argument to prevent command injection + // from & | < > ^ ( ), but % and ! remain a best-effort limitation + // of the cmd.exe shell. Without refactoring to bypass cmd.exe /c + // entirely (e.g. parse the .cmd shim and invoke the underlying + // binary directly), this expansion cannot be fully avoided. Log a + // warning so users are aware. + // + // Additionally, cmd.exe does not treat backslash as a quote escape, + // so arguments containing a literal double quote cannot be safely + // passed through cmd.exe /c. We reject such arguments. + for arg in native_args { + let s = arg.to_string_lossy(); + if s.contains('%') || s.contains('!') { + log::warn!( + target: "claude_temp_launch", + "Native arg contains % or ! which cmd.exe may expand: {}", + s + ); + } + if s.contains('"') { + return Err(AppError::localized( + "claude.temp_launch_unsafe_cmd_quote", + format!( + "参数包含双引号,无法安全地通过 cmd.exe /c 传递: {}", + s + ), + format!( + "Native arg contains a double quote which cannot be safely passed through cmd.exe /c: {}", + s + ), + )); + } + if s.ends_with('\\') { + return Err(AppError::localized( + "claude.temp_launch_unsafe_cmd_trailing_backslash", + format!( + "参数以反斜杠结尾,无法安全地通过 cmd.exe /c 传递: {}", + s + ), + format!( + "Native arg ends with a backslash which cannot be safely passed through cmd.exe /c: {}", + s + ), + )); + } + } + cmdline.push_str("cmd.exe /c "); + cmdline.push_str("e_windows_arg_for_cmd(&exe_str)); + cmdline.push_str(" --settings "); + cmdline.push_str("e_windows_arg_for_cmd( + &prepared.settings_path.to_string_lossy(), + )); + for arg in native_args { + cmdline.push(' '); + cmdline.push_str("e_windows_arg_for_cmd(&arg.to_string_lossy())); + } + } else { + cmdline.push_str("e_windows_arg(&exe_str)); + cmdline.push_str(" --settings "); + cmdline.push_str("e_windows_arg( + &prepared.settings_path.to_string_lossy(), + )); + for arg in native_args { + cmdline.push(' '); + cmdline.push_str("e_windows_arg(&arg.to_string_lossy())); + } + } + + Ok(cmdline.encode_utf16().chain(std::iter::once(0)).collect()) +} + +#[cfg(windows)] +fn quote_windows_arg(arg: &str) -> String { + if !arg.is_empty() + && !arg.contains(' ') + && !arg.contains('\t') + && !arg.contains('\n') + && !arg.contains('"') + { + return arg.to_string(); + } + + let mut result = String::with_capacity(arg.len() + 2); + result.push('"'); + + let mut chars = arg.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '"' { + result.push('\\'); + result.push('"'); + } else if ch == '\\' { + let mut count = 1; + while chars.peek() == Some(&'\\') { + count += 1; + chars.next(); + } + if chars.peek() == Some(&'"') || chars.peek().is_none() { + // Double backslashes when followed by a quote or end of string + for _ in 0..count * 2 { + result.push('\\'); + } + } else { + for _ in 0..count { + result.push('\\'); + } + } + } else { + result.push(ch); + } + } + + result.push('"'); + result +} + +#[cfg(windows)] +fn quote_windows_arg_for_cmd(arg: &str) -> String { + const CMD_SPECIAL: &[char] = &['&', '|', '<', '>', '^', '%', '!', '(', ')']; + let needs_quote = arg.is_empty() + || arg.contains(' ') + || arg.contains('\t') + || arg.contains('\n') + || arg.contains('"') + || arg.chars().any(|c| CMD_SPECIAL.contains(&c)); + + if !needs_quote { + return arg.to_string(); + } + + let mut result = String::with_capacity(arg.len() + 2); + result.push('"'); + + let mut chars = arg.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '"' { + result.push('\\'); + result.push('"'); + } else if ch == '\\' { + let mut count = 1; + while chars.peek() == Some(&'\\') { + count += 1; + chars.next(); + } + if chars.peek() == Some(&'"') || chars.peek().is_none() { + for _ in 0..count * 2 { + result.push('\\'); + } + } else { + for _ in 0..count { + result.push('\\'); + } + } + } else { + result.push(ch); + } + } + + result.push('"'); + result +} + +#[cfg(windows)] pub(crate) fn exec_prepared_claude( - _prepared: &PreparedClaudeLaunch, - _native_args: &[OsString], + prepared: &PreparedClaudeLaunch, + native_args: &[OsString], ) -> Result<(), AppError> { - Err(AppError::localized( - "claude.temp_launch_unsupported_platform", - "当前平台暂不支持在当前终端临时启动 Claude。".to_string(), - "Temporary Claude launch in the current terminal is not supported on this platform." - .to_string(), - )) + use windows_sys::Win32::Foundation::{CloseHandle, FALSE}; + use windows_sys::Win32::System::Threading::{ + CreateProcessW, GetExitCodeProcess, ResumeThread, WaitForSingleObject, INFINITE, + PROCESS_INFORMATION, STARTUPINFOW, + }; + + let _ctrl_guard = ScopedConsoleCtrlHandler::install()?; + + let mut cmdline_wide = build_windows_cmdline(prepared, native_args)?; + + let exe_str = prepared.executable.to_string_lossy(); + let is_cmd = exe_str.ends_with(".cmd") || exe_str.ends_with(".bat"); + + let application_name_wide: Option> = if is_cmd { + None + } else { + Some( + std::ffi::OsStr::new(&*prepared.executable) + .encode_wide() + .chain(std::iter::once(0)) + .collect(), + ) + }; + + let mut startup_info: STARTUPINFOW = unsafe { std::mem::zeroed() }; + startup_info.cb = std::mem::size_of::() as u32; + + let mut process_info: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + + let app_name_ptr = application_name_wide + .as_ref() + .map(|s| s.as_ptr()) + .unwrap_or(std::ptr::null()); + + let create_result = unsafe { + CreateProcessW( + app_name_ptr, + cmdline_wide.as_mut_ptr(), + std::ptr::null(), + std::ptr::null(), + FALSE, + 0x00000004, + std::ptr::null(), + std::ptr::null(), + &startup_info, + &mut process_info, + ) + }; + + if create_result == 0 { + return Err(AppError::localized( + "windows.create_process_failed", + "创建进程失败".to_string(), + format!( + "Failed to create process: {}", + std::io::Error::last_os_error() + ), + )); + } + + let h_process = process_info.hProcess; + let h_thread = process_info.hThread; + + let job = match unsafe { Job::create_with_kill_on_close() } { + Ok(job) => job, + Err(e) => { + unsafe { + let _ = windows_sys::Win32::System::Threading::TerminateProcess(h_process, 1); + CloseHandle(h_thread); + CloseHandle(h_process); + } + return Err(e); + } + }; + + if let Err(e) = unsafe { job.try_assign(h_process) } { + log::warn!(target: "windows.job_assign_failed_fallback", "{}", e); + } + + let resume_result = unsafe { ResumeThread(h_thread) }; + if resume_result == u32::MAX { + unsafe { + job.terminate(); + CloseHandle(h_thread); + CloseHandle(h_process); + } + return Err(AppError::localized( + "windows.resume_thread_failed", + "恢复线程失败".to_string(), + format!( + "Failed to resume thread: {}", + std::io::Error::last_os_error() + ), + )); + } + + unsafe { + WaitForSingleObject(h_process, INFINITE); + } + + let mut exit_code: u32 = 0; + let get_exit_result = unsafe { GetExitCodeProcess(h_process, &mut exit_code) }; + + unsafe { + CloseHandle(h_thread); + CloseHandle(h_process); + } + + if get_exit_result == 0 { + return Err(AppError::localized( + "windows.get_exit_code_failed", + "获取进程退出码失败".to_string(), + format!( + "Failed to get exit code: {}", + std::io::Error::last_os_error() + ), + )); + } + + if exit_code != 0 { + return Err(AppError::Message(format!( + "Claude exited with code {}", + exit_code + ))); + } + + Ok(()) } fn write_temp_settings_file( @@ -167,6 +599,9 @@ fn write_temp_settings_file_with( where Finalize: FnOnce(&Path) -> Result<(), AppError>, { + #[cfg(windows)] + let timestamp = crate::cli::orphan_scan::current_process_creation_time_nanos(); + #[cfg(not(windows))] let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -217,7 +652,11 @@ fn finalize_temp_settings_file(path: &Path) -> Result<(), AppError> { } #[cfg(not(unix))] -fn finalize_temp_settings_file(_path: &Path) -> Result<(), AppError> { +fn finalize_temp_settings_file(path: &Path) -> Result<(), AppError> { + #[cfg(windows)] + { + restrict_to_owner(path, false)?; + } Ok(()) } @@ -235,11 +674,123 @@ fn create_secret_temp_file(path: &Path) -> Result { #[cfg(not(unix))] fn create_secret_temp_file(path: &Path) -> Result { - OpenOptions::new() + let file = OpenOptions::new() .write(true) .create_new(true) .open(path) - .map_err(|err| AppError::io(path, err)) + .map_err(|err| AppError::io(path, err))?; + #[cfg(windows)] + { + restrict_to_owner(path, false)?; + } + Ok(file) +} + +#[cfg(windows)] +fn restrict_to_owner(path: &Path, inherit: bool) -> Result<(), AppError> { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::{CloseHandle, ERROR_SUCCESS, HANDLE}; + use windows_sys::Win32::Security::Authorization::{ + SetNamedSecurityInfoW, SE_FILE_OBJECT, + }; + use windows_sys::Win32::Security::{ + ACL, AddAccessAllowedAceEx, DACL_SECURITY_INFORMATION, + GetLengthSid, GetTokenInformation, InitializeAcl, + PROTECTED_DACL_SECURITY_INFORMATION, + TOKEN_QUERY, TOKEN_USER, TokenUser, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + const NO_INHERITANCE: u32 = 0; + const OBJECT_INHERIT_ACE: u32 = 0x1; + const CONTAINER_INHERIT_ACE: u32 = 0x2; + const FILE_ALL_ACCESS: u32 = 0x1F01FF; + const ACL_REVISION: u32 = 2; + + // Open current process token to get the user SID + let mut token: HANDLE = std::ptr::null_mut(); + let result = unsafe { + OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) + }; + if result == 0 { + return Err(AppError::io(path, std::io::Error::last_os_error())); + } + + // Get token user info (first call to get size) + let mut size = 0u32; + unsafe { + GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut size); + } + + let mut buffer = vec![0u8; size as usize]; + let result = unsafe { + GetTokenInformation( + token, + TokenUser, + buffer.as_mut_ptr() as *mut _, + size, + &mut size, + ) + }; + if result == 0 { + unsafe { CloseHandle(token) }; + return Err(AppError::io(path, std::io::Error::last_os_error())); + } + + let token_user = unsafe { &*(buffer.as_ptr() as *const TOKEN_USER) }; + let user_sid = token_user.User.Sid; + + unsafe { CloseHandle(token) }; + + let sid_len = unsafe { GetLengthSid(user_sid) }; + + // ACL size = ACL header + ACCESS_ALLOWED_ACE without SidStart + SID length + // ACL header = 8 bytes, ACE header+Mask = 8 bytes, SidStart = 4 bytes + let acl_size = (std::mem::size_of::() + 8 + sid_len as usize) as u32; + let mut acl_buffer = vec![0u8; acl_size as usize]; + let acl = acl_buffer.as_mut_ptr() as *mut ACL; + + let result = unsafe { InitializeAcl(acl, acl_size, ACL_REVISION) }; + if result == 0 { + return Err(AppError::io(path, std::io::Error::last_os_error())); + } + + let ace_flags = if inherit { + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + } else { + NO_INHERITANCE + }; + + let result = unsafe { + AddAccessAllowedAceEx(acl, ACL_REVISION, ace_flags, FILE_ALL_ACCESS, user_sid) + }; + if result == 0 { + return Err(AppError::io(path, std::io::Error::last_os_error())); + } + + let path_wide: Vec = OsStr::new(path) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let result = unsafe { + SetNamedSecurityInfoW( + path_wide.as_ptr() as *mut _, + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + acl, + std::ptr::null_mut(), + ) + }; + + if result != ERROR_SUCCESS { + return Err(AppError::io(path, std::io::Error::from_raw_os_error(result as i32))); + } + + Ok(()) } fn cleanup_temp_settings_file(path: &Path) -> Result<(), AppError> { @@ -597,6 +1148,51 @@ mod tests { assert_eq!(written["permissions"]["allow"], json!(["Bash(git*)"])); } + #[cfg(windows)] + #[test] + fn quote_windows_arg_for_cmd_quotes_special_chars() { + assert_eq!(quote_windows_arg_for_cmd("foo&bar"), "\"foo&bar\""); + assert_eq!(quote_windows_arg_for_cmd("foo|bar"), "\"foo|bar\""); + assert_eq!(quote_windows_arg_for_cmd("foobar"), "\"foo>bar\""); + assert_eq!(quote_windows_arg_for_cmd("foo^bar"), "\"foo^bar\""); + assert_eq!(quote_windows_arg_for_cmd("foo%bar"), "\"foo%bar\""); + assert_eq!(quote_windows_arg_for_cmd("foo!bar"), "\"foo!bar\""); + assert_eq!(quote_windows_arg_for_cmd("foo(bar"), "\"foo(bar\""); + assert_eq!(quote_windows_arg_for_cmd("foo)bar"), "\"foo)bar\""); + } + + #[cfg(windows)] + #[test] + fn quote_windows_arg_for_cmd_escapes_quotes() { + assert_eq!( + quote_windows_arg_for_cmd("foo\"bar"), + "\"foo\\\"bar\"" + ); + } + + #[cfg(windows)] + #[test] + fn quote_windows_arg_for_cmd_quotes_spaces_and_specials() { + assert_eq!( + quote_windows_arg_for_cmd("foo & bar"), + "\"foo & bar\"" + ); + } + + #[cfg(windows)] + #[test] + fn quote_windows_arg_for_cmd_leaves_plain_args_unchanged() { + assert_eq!(quote_windows_arg_for_cmd("normal"), "normal"); + assert_eq!(quote_windows_arg_for_cmd("C:\\path\\file.exe"), "C:\\path\\file.exe"); + } + + #[cfg(windows)] + #[test] + fn quote_windows_arg_for_cmd_handles_empty_string() { + assert_eq!(quote_windows_arg_for_cmd(""), "\"\""); + } + #[test] fn prepare_launch_from_settings_writes_exact_effective_snapshot() { let temp_dir = TempDir::new().expect("create temp dir"); diff --git a/src-tauri/src/cli/codex_temp_launch.rs b/src-tauri/src/cli/codex_temp_launch.rs index e036d4b8..323f5c05 100644 --- a/src-tauri/src/cli/codex_temp_launch.rs +++ b/src-tauri/src/cli/codex_temp_launch.rs @@ -9,6 +9,26 @@ use crate::error::AppError; use crate::provider::Provider; use serde_json::Value; +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt; +#[cfg(windows)] +use std::ptr; +#[cfg(windows)] +use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, FALSE, HANDLE, TRUE}; +#[cfg(windows)] +use windows_sys::Win32::System::Console::SetConsoleCtrlHandler; +#[cfg(windows)] +use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, + SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, +}; +#[cfg(windows)] +use windows_sys::Win32::System::Threading::{ + CreateProcessW, GetExitCodeProcess, ResumeThread, TerminateProcess, WaitForSingleObject, + CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, INFINITE, PROCESS_INFORMATION, STARTUPINFOW, +}; + #[derive(Debug, Clone)] pub(crate) struct PreparedCodexLaunch { pub(crate) executable: PathBuf, @@ -59,7 +79,12 @@ pub(crate) fn ensure_temp_launch_supported() -> Result<(), AppError> { Ok(()) } -#[cfg(not(unix))] +#[cfg(windows)] +pub(crate) fn ensure_temp_launch_supported() -> Result<(), AppError> { + Ok(()) +} + +#[cfg(not(any(unix, windows)))] pub(crate) fn ensure_temp_launch_supported() -> Result<(), AppError> { Err(AppError::localized( "codex.temp_launch_unsupported_platform", @@ -100,7 +125,58 @@ pub(crate) fn exec_prepared_codex( )) } -#[cfg(not(unix))] +#[cfg(windows)] +pub(crate) fn exec_prepared_codex( + prepared: &PreparedCodexLaunch, + native_args: &[OsString], +) -> Result<(), AppError> { + let _ctrl_guard = ScopedConsoleCtrlHandler::install()?; + + let (program, args) = build_command_windows(prepared, native_args)?; + + let env_block = build_env_block_with_override("CODEX_HOME", prepared.codex_home.as_os_str()); + + let (process_handle, thread_handle) = spawn_suspended_createprocessw(&program, &args, Some(&env_block))?; + + let job = Job::create_with_kill_on_close()?; + + if let Err(e) = job.try_assign(process_handle) { + log::warn!("{}", AppError::windows_job_assign_failed_fallback(&e)); + } + + let resume_result = unsafe { ResumeThread(thread_handle) }; + if resume_result == u32::MAX { + let code = unsafe { GetLastError() }; + unsafe { + let _ = TerminateProcess(process_handle, 1); + CloseHandle(thread_handle); + CloseHandle(process_handle); + } + return Err(AppError::windows_resume_thread_failed(code)); + } + + unsafe { CloseHandle(thread_handle) }; + + let exit_code = match wait_for_child(process_handle) { + Ok(code) => code, + Err(e) => { + unsafe { CloseHandle(process_handle) }; + return Err(e); + } + }; + unsafe { CloseHandle(process_handle) }; + + if exit_code != 0 { + return Err(AppError::localized( + "codex.temp_launch_exit_nonzero", + format!("Codex 进程退出码非零: {exit_code}"), + format!("Codex process exited with non-zero code: {exit_code}"), + )); + } + Ok(()) +} + +#[cfg(not(any(unix, windows)))] pub(crate) fn exec_prepared_codex( _prepared: &PreparedCodexLaunch, _native_args: &[OsString], @@ -113,6 +189,372 @@ pub(crate) fn exec_prepared_codex( )) } +#[cfg(windows)] +fn build_command_windows( + prepared: &PreparedCodexLaunch, + native_args: &[OsString], +) -> Result<(std::path::PathBuf, Vec), AppError> { + let exec_str = prepared.executable.to_string_lossy(); + if exec_str.ends_with(".cmd") || exec_str.ends_with(".bat") { + // cmd.exe expands %VAR% and !VAR! (delayed expansion) even inside + // double quotes. There is no standard escape for these in a /c + // command line. Without refactoring to bypass cmd.exe /c entirely + // (e.g. parse the .cmd shim and invoke the underlying binary + // directly), this expansion cannot be fully avoided. Log a warning + // so users are aware. + // + // Additionally, cmd.exe does not treat backslash as a quote escape, + // so arguments containing a literal double quote cannot be safely + // passed through cmd.exe /c. We reject such arguments. + for arg in native_args { + let s = arg.to_string_lossy(); + if s.contains('%') || s.contains('!') { + log::warn!( + target: "codex_temp_launch", + "Native arg contains % or ! which cmd.exe may expand: {}", + s + ); + } + if s.contains('"') { + return Err(AppError::localized( + "codex.temp_launch_unsafe_cmd_quote", + format!( + "参数包含双引号,无法安全地通过 cmd.exe /c 传递: {}", + s + ), + format!( + "Native arg contains a double quote which cannot be safely passed through cmd.exe /c: {}", + s + ), + )); + } + if s.ends_with('\\') { + return Err(AppError::localized( + "codex.temp_launch_unsafe_cmd_trailing_backslash", + format!( + "参数以反斜杠结尾,无法安全地通过 cmd.exe /c 传递: {}", + s + ), + format!( + "Native arg ends with a backslash which cannot be safely passed through cmd.exe /c: {}", + s + ), + )); + } + } + let mut args = vec![OsString::from("/c"), OsString::from(&prepared.executable)]; + args.extend_from_slice(native_args); + Ok((std::path::PathBuf::from("cmd.exe"), args)) + } else { + Ok((prepared.executable.clone(), native_args.to_vec())) + } +} + +#[cfg(windows)] +fn build_windows_command_line(program: &std::ffi::OsStr, args: &[OsString]) -> Vec { + let program_str = program.to_string_lossy(); + let is_cmd = program_str.eq_ignore_ascii_case("cmd.exe") + || program_str.eq_ignore_ascii_case("cmd"); + + let mut line = String::new(); + line.push_str("e_windows_arg(&program_str)); + + let mut after_c = false; + for arg in args { + line.push(' '); + let arg_str = arg.to_string_lossy(); + if is_cmd && after_c { + line.push_str("e_windows_arg_for_cmd(&arg_str)); + } else { + line.push_str("e_windows_arg(&arg_str)); + if is_cmd && arg_str.eq_ignore_ascii_case("/c") { + after_c = true; + } + } + } + std::ffi::OsStr::new(&line) + .encode_wide() + .chain(Some(0)) + .collect() +} + +#[cfg(windows)] +fn quote_windows_arg(arg: &str) -> String { + if arg.is_empty() { + return "\"\"".to_string(); + } + if !arg.contains(' ') && !arg.contains('\t') && !arg.contains('\n') && !arg.contains('"') { + return arg.to_string(); + } + let mut result = String::with_capacity(arg.len() + 2); + result.push('"'); + let mut chars = arg.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '"' { + result.push('\\'); + result.push('"'); + } else if ch == '\\' { + let mut count = 1; + while chars.peek() == Some(&'\\') { + count += 1; + chars.next(); + } + if chars.peek() == Some(&'"') || chars.peek().is_none() { + for _ in 0..count * 2 { + result.push('\\'); + } + } else { + for _ in 0..count { + result.push('\\'); + } + } + } else { + result.push(ch); + } + } + result.push('"'); + result +} + +#[cfg(windows)] +fn quote_windows_arg_for_cmd(arg: &str) -> String { + const CMD_SPECIAL: &[char] = &['&', '|', '<', '>', '^', '%', '!', '(', ')']; + let needs_quote = arg.is_empty() + || arg.contains(' ') + || arg.contains('\t') + || arg.contains('\n') + || arg.contains('"') + || arg.chars().any(|c| CMD_SPECIAL.contains(&c)); + + if !needs_quote { + return arg.to_string(); + } + + let mut result = String::with_capacity(arg.len() + 2); + result.push('"'); + let mut chars = arg.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '"' { + result.push('\\'); + result.push('"'); + } else if ch == '\\' { + let mut count = 1; + while chars.peek() == Some(&'\\') { + count += 1; + chars.next(); + } + if chars.peek() == Some(&'"') || chars.peek().is_none() { + for _ in 0..count * 2 { + result.push('\\'); + } + } else { + for _ in 0..count { + result.push('\\'); + } + } + } else { + result.push(ch); + } + } + result.push('"'); + result +} + +#[cfg(windows)] +fn spawn_suspended_createprocessw( + program: &std::path::Path, + args: &[OsString], + env_block: Option<&[u16]>, +) -> Result<(HANDLE, HANDLE), AppError> { + let program_wide: Vec = std::ffi::OsStr::new(program) + .encode_wide() + .chain(Some(0)) + .collect(); + + let mut command_line = build_windows_command_line(std::ffi::OsStr::new(program), args); + + let mut startup_info: STARTUPINFOW = unsafe { std::mem::zeroed() }; + startup_info.cb = std::mem::size_of::() as u32; + + let mut process_info: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + + let env_ptr = env_block + .map(|b| b.as_ptr() as *mut _) + .unwrap_or(ptr::null_mut()); + + let result = unsafe { + CreateProcessW( + program_wide.as_ptr(), + command_line.as_mut_ptr(), + ptr::null_mut(), + ptr::null_mut(), + FALSE, + CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT, + env_ptr, + ptr::null(), + &startup_info, + &mut process_info, + ) + }; + + if result == 0 { + let code = unsafe { GetLastError() }; + return Err(AppError::windows_create_process_failed(code)); + } + + Ok((process_info.hProcess, process_info.hThread)) +} + +#[cfg(windows)] +struct Job { + handle: HANDLE, +} + +#[cfg(windows)] +impl Job { + fn create_with_kill_on_close() -> Result { + unsafe { + let handle = CreateJobObjectW(ptr::null_mut(), ptr::null()); + if handle.is_null() { + let code = GetLastError(); + return Err(AppError::localized( + "windows.create_job_object_failed", + format!("创建 Job Object 失败,Win32 错误码: {code}"), + format!("Failed to create Job Object, Win32 error: {code}"), + )); + } + + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + + let result = SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + &mut info as *mut _ as *mut _, + std::mem::size_of::() as u32, + ); + + if result == 0 { + let code = GetLastError(); + CloseHandle(handle); + return Err(AppError::localized( + "windows.set_job_information_failed", + format!("设置 Job Object 信息失败,Win32 错误码: {code}"), + format!("Failed to set Job Object information, Win32 error: {code}"), + )); + } + + Ok(Job { handle }) + } + } + + fn try_assign(&self, process: HANDLE) -> Result<(), std::io::Error> { + unsafe { + let result = AssignProcessToJobObject(self.handle, process); + if result == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + } + } +} + +#[cfg(windows)] +impl Drop for Job { + fn drop(&mut self) { + unsafe { + CloseHandle(self.handle); + } + } +} + +#[cfg(windows)] +struct ScopedConsoleCtrlHandler; + +#[cfg(windows)] +impl ScopedConsoleCtrlHandler { + fn install() -> Result { + unsafe { + let result = SetConsoleCtrlHandler(Some(ctrl_handler_swallow), TRUE); + if result == 0 { + return Err(AppError::localized( + "windows.set_console_ctrl_handler_failed", + "设置控制台 Ctrl 处理器失败".to_string(), + "Failed to set console Ctrl handler.".to_string(), + )); + } + } + Ok(ScopedConsoleCtrlHandler) + } +} + +#[cfg(windows)] +impl Drop for ScopedConsoleCtrlHandler { + fn drop(&mut self) { + unsafe { + SetConsoleCtrlHandler(Some(ctrl_handler_swallow), FALSE); + } + } +} + +#[cfg(windows)] +unsafe extern "system" fn ctrl_handler_swallow(_ctrl_type: u32) -> i32 { + TRUE +} + +#[cfg(windows)] +fn wait_for_child(process_handle: HANDLE) -> Result { + unsafe { + let wait_result = WaitForSingleObject(process_handle, INFINITE); + if wait_result != 0 { + let code = GetLastError(); + return Err(AppError::localized( + "windows.wait_for_child_failed", + format!("等待子进程失败,Win32 错误码: {code}"), + format!("Failed to wait for child process, Win32 error: {code}"), + )); + } + + let mut exit_code: u32 = 0; + if GetExitCodeProcess(process_handle, &mut exit_code) == 0 { + let code = GetLastError(); + return Err(AppError::localized( + "windows.get_exit_code_failed", + format!("获取子进程退出码失败,Win32 错误码: {code}"), + format!("Failed to get child exit code, Win32 error: {code}"), + )); + } + + Ok(exit_code) + } +} + +#[cfg(windows)] +fn build_env_block_with_override(key: &str, value: &std::ffi::OsStr) -> Vec { + use std::os::windows::ffi::OsStrExt; + + let mut result = Vec::new(); + for (k, v) in std::env::vars_os() { + // Windows environment variable names are case-insensitive. + if k.to_string_lossy().eq_ignore_ascii_case(key) { + continue; + } + result.extend(k.encode_wide()); + result.push(b'=' as u16); + result.extend(v.encode_wide()); + result.push(0); + } + // Add our override + result.extend(key.encode_utf16()); + result.push(b'=' as u16); + result.extend(value.encode_wide()); + result.push(0); + // Double-null terminate the block + result.push(0); + result +} + fn write_temp_codex_home(temp_dir: &Path, provider: &Provider) -> Result { write_temp_codex_home_with(temp_dir, provider, finalize_temp_codex_home) } @@ -161,6 +603,9 @@ where } }; + #[cfg(windows)] + let timestamp = crate::cli::orphan_scan::current_process_creation_time_nanos(); + #[cfg(not(windows))] let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -213,7 +658,11 @@ fn finalize_temp_codex_home(path: &Path) -> Result<(), AppError> { } #[cfg(not(unix))] -fn finalize_temp_codex_home(_path: &Path) -> Result<(), AppError> { +fn finalize_temp_codex_home(path: &Path) -> Result<(), AppError> { + #[cfg(windows)] + { + restrict_to_owner(path, true)?; + } Ok(()) } @@ -238,11 +687,121 @@ fn create_secret_temp_file(path: &Path) -> Result { #[cfg(not(unix))] fn create_secret_temp_file(path: &Path) -> Result { - OpenOptions::new() + let file = OpenOptions::new() .write(true) .create_new(true) .open(path) - .map_err(|err| AppError::io(path, err)) + .map_err(|err| AppError::io(path, err))?; + #[cfg(windows)] + { + restrict_to_owner(path, false)?; + } + Ok(file) +} + +#[cfg(windows)] +fn restrict_to_owner(path: &Path, inherit: bool) -> Result<(), AppError> { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::{CloseHandle, ERROR_SUCCESS, HANDLE}; + use windows_sys::Win32::Security::Authorization::{ + SetNamedSecurityInfoW, SE_FILE_OBJECT, + }; + use windows_sys::Win32::Security::{ + ACL, AddAccessAllowedAceEx, DACL_SECURITY_INFORMATION, + GetLengthSid, GetTokenInformation, InitializeAcl, + PROTECTED_DACL_SECURITY_INFORMATION, + TOKEN_QUERY, TOKEN_USER, TokenUser, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + const NO_INHERITANCE: u32 = 0; + const OBJECT_INHERIT_ACE: u32 = 0x1; + const CONTAINER_INHERIT_ACE: u32 = 0x2; + const FILE_ALL_ACCESS: u32 = 0x1F01FF; + const ACL_REVISION: u32 = 2; + + // Open current process token to get the user SID + let mut token: HANDLE = std::ptr::null_mut(); + let result = unsafe { + OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) + }; + if result == 0 { + return Err(AppError::io(path, std::io::Error::last_os_error())); + } + + // Get token user info (first call to get size) + let mut size = 0u32; + unsafe { + GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut size); + } + + let mut buffer = vec![0u8; size as usize]; + let result = unsafe { + GetTokenInformation( + token, + TokenUser, + buffer.as_mut_ptr() as *mut _, + size, + &mut size, + ) + }; + if result == 0 { + unsafe { CloseHandle(token) }; + return Err(AppError::io(path, std::io::Error::last_os_error())); + } + + let token_user = unsafe { &*(buffer.as_ptr() as *const TOKEN_USER) }; + let user_sid = token_user.User.Sid; + + unsafe { CloseHandle(token) }; + + let sid_len = unsafe { GetLengthSid(user_sid) }; + + let acl_size = (std::mem::size_of::() + 8 + sid_len as usize) as u32; + let mut acl_buffer = vec![0u8; acl_size as usize]; + let acl = acl_buffer.as_mut_ptr() as *mut ACL; + + let result = unsafe { InitializeAcl(acl, acl_size, ACL_REVISION) }; + if result == 0 { + return Err(AppError::io(path, std::io::Error::last_os_error())); + } + + let ace_flags = if inherit { + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + } else { + NO_INHERITANCE + }; + + let result = unsafe { + AddAccessAllowedAceEx(acl, ACL_REVISION, ace_flags, FILE_ALL_ACCESS, user_sid) + }; + if result == 0 { + return Err(AppError::io(path, std::io::Error::last_os_error())); + } + + let path_wide: Vec = OsStr::new(path) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let result = unsafe { + SetNamedSecurityInfoW( + path_wide.as_ptr() as *mut _, + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + acl, + std::ptr::null_mut(), + ) + }; + + if result != ERROR_SUCCESS { + return Err(AppError::io(path, std::io::Error::from_raw_os_error(result as i32))); + } + + Ok(()) } fn cleanup_temp_codex_home(path: &Path) -> Result<(), AppError> { @@ -281,6 +840,57 @@ mod tests { use std::time::Duration; use tempfile::TempDir; + #[cfg(windows)] + #[test] + fn quote_windows_arg_for_cmd_quotes_special_chars() { + assert_eq!(quote_windows_arg_for_cmd("foo&bar"), "\"foo&bar\""); + assert_eq!(quote_windows_arg_for_cmd("foo|bar"), "\"foo|bar\""); + assert_eq!(quote_windows_arg_for_cmd("foobar"), "\"foo>bar\""); + assert_eq!(quote_windows_arg_for_cmd("foo^bar"), "\"foo^bar\""); + assert_eq!(quote_windows_arg_for_cmd("foo%bar"), "\"foo%bar\""); + assert_eq!(quote_windows_arg_for_cmd("foo!bar"), "\"foo!bar\""); + assert_eq!(quote_windows_arg_for_cmd("foo(bar"), "\"foo(bar\""); + assert_eq!(quote_windows_arg_for_cmd("foo)bar"), "\"foo)bar\""); + } + + #[cfg(windows)] + #[test] + fn quote_windows_arg_for_cmd_escapes_quotes() { + assert_eq!( + quote_windows_arg_for_cmd("foo\"bar"), + "\"foo\\\"bar\"" + ); + } + + #[cfg(windows)] + #[test] + fn quote_windows_arg_for_cmd_quotes_spaces_and_specials() { + assert_eq!( + quote_windows_arg_for_cmd("foo & bar"), + "\"foo & bar\"" + ); + } + + #[cfg(windows)] + #[test] + fn quote_windows_arg_for_cmd_leaves_plain_args_unchanged() { + assert_eq!(quote_windows_arg_for_cmd("normal"), "normal"); + assert_eq!(quote_windows_arg_for_cmd("C:\\path\\file.exe"), "C:\\path\\file.exe"); + } + + #[cfg(windows)] + #[test] + fn quote_windows_arg_for_cmd_handles_empty_string() { + assert_eq!(quote_windows_arg_for_cmd(""), "\"\""); + } + + #[cfg(windows)] + #[test] + fn quote_windows_arg_handles_newlines() { + assert_eq!(quote_windows_arg("foo\nbar"), "\"foo\nbar\""); + } + #[cfg(unix)] fn write_test_executable(temp_dir: &TempDir, name: &str, body: &str) -> PathBuf { let path = temp_dir.path().join(name); diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index 12cf8b1e..bcb0dfa5 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -8,6 +8,7 @@ pub mod commands; pub mod editor; pub mod i18n; pub mod interactive; +pub mod orphan_scan; pub mod terminal; pub mod tui; pub mod ui; @@ -61,7 +62,7 @@ pub enum Commands { Proxy(commands::proxy::ProxyCommand), /// Start an app with a provider selector without switching the global current provider - #[cfg(unix)] + #[cfg(any(unix, windows))] #[command(subcommand)] Start(commands::start::StartCommand), @@ -184,7 +185,7 @@ mod tests { } } - #[cfg(unix)] + #[cfg(any(unix, windows))] #[test] fn parses_start_claude_subcommand() { let cli = Cli::parse_from(["cc-switch", "start", "claude", "demo"]); @@ -201,7 +202,7 @@ mod tests { } } - #[cfg(unix)] + #[cfg(any(unix, windows))] #[test] fn parses_start_claude_native_args_after_double_dash() { let cli = Cli::parse_from([ @@ -228,7 +229,7 @@ mod tests { } } - #[cfg(unix)] + #[cfg(any(unix, windows))] #[test] fn rejects_start_claude_native_args_without_double_dash() { let result = Cli::try_parse_from([ @@ -246,7 +247,7 @@ mod tests { assert!(rendered.contains("-- --dangerously-skip-permissions")); } - #[cfg(unix)] + #[cfg(any(unix, windows))] #[test] fn parses_start_codex_subcommand() { let cli = Cli::parse_from(["cc-switch", "start", "codex", "demo"]); @@ -263,7 +264,7 @@ mod tests { } } - #[cfg(unix)] + #[cfg(any(unix, windows))] #[test] fn parses_start_codex_multiple_native_args_after_double_dash() { let cli = Cli::parse_from([ @@ -298,7 +299,7 @@ mod tests { } } - #[cfg(unix)] + #[cfg(any(unix, windows))] #[test] fn start_claude_help_mentions_double_dash_passthrough_examples() { let mut cmd = Cli::command(); @@ -314,7 +315,7 @@ mod tests { assert!(help.contains("cc-switch start claude demo -- --dangerously-skip-permissions")); } - #[cfg(unix)] + #[cfg(any(unix, windows))] #[test] fn start_codex_help_mentions_double_dash_passthrough_examples() { let mut cmd = Cli::command(); diff --git a/src-tauri/src/cli/orphan_scan.rs b/src-tauri/src/cli/orphan_scan.rs new file mode 100644 index 00000000..c4231972 --- /dev/null +++ b/src-tauri/src/cli/orphan_scan.rs @@ -0,0 +1,426 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +/// Information extracted from a temp file/directory name. +struct TempEntryInfo { + path: PathBuf, + pid: u32, + nanos: u128, +} + +/// Scan the temp directory for orphaned cc-switch temp files/directories +/// and clean them up. Returns the number of entries removed. +/// +/// This is a best-effort operation: errors are logged but never propagated. +pub fn scan_and_clean(temp_dir: &Path) -> usize { + let entries = match collect_cc_switch_entries(temp_dir) { + Ok(entries) => entries, + Err(e) => { + log::warn!(target: "orphan_scan", "Failed to read temp dir {}: {}", temp_dir.display(), e); + return 0; + } + }; + + let mut cleaned = 0; + for entry in entries { + if should_clean(&entry) { + if let Err(e) = remove_entry(&entry.path) { + log::warn!(target: "orphan_scan", "Failed to remove {}: {}", entry.path.display(), e); + } else { + log::debug!(target: "orphan_scan", "Cleaned orphaned temp entry: {}", entry.path.display()); + cleaned += 1; + } + } + } + + cleaned +} + +fn collect_cc_switch_entries(temp_dir: &Path) -> Result, std::io::Error> { + let mut entries = Vec::new(); + let dir = fs::read_dir(temp_dir)?; + + for entry in dir { + let entry = entry?; + let name = entry.file_name(); + let name_str = match name.to_str() { + Some(s) => s, + None => continue, + }; + + if let Some(info) = parse_cc_switch_name(name_str, entry.path()) { + entries.push(info); + } + } + + Ok(entries) +} + +fn parse_cc_switch_name(name: &str, path: PathBuf) -> Option { + let rest = name + .strip_prefix("cc-switch-claude-") + .or_else(|| name.strip_prefix("cc-switch-codex-"))?; + let rest = rest.strip_suffix(".json").unwrap_or(rest); + let parts: Vec<&str> = rest.split('-').collect(); + if parts.len() < 2 { + return None; + } + + let nanos = parts.last()?.parse::().ok()?; + let pid = parts.get(parts.len() - 2)?.parse::().ok()?; + + Some(TempEntryInfo { path, pid, nanos }) +} + +fn should_clean(entry: &TempEntryInfo) -> bool { + // Living PID = leave alone. Dead PID = clean immediately: the file's + // owner process is gone, so the file is a true orphan regardless of age. + !is_pid_alive(entry.pid, entry.nanos) +} + +#[cfg(windows)] +pub(crate) fn current_process_creation_time_nanos() -> u128 { + use windows_sys::Win32::Foundation::{FILETIME, GetLastError}; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetProcessTimes}; + + unsafe { + let mut creation_time = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let mut exit_time = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let mut kernel_time = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let mut user_time = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + + let result = GetProcessTimes( + GetCurrentProcess(), + &mut creation_time, + &mut exit_time, + &mut kernel_time, + &mut user_time, + ); + + if result == 0 { + // Fall back to current time if we can't read process creation time. + // This is extremely unlikely but keeps the function infallible. + log::warn!( + target: "orphan_scan", + "GetProcessTimes failed ({}); falling back to SystemTime::now()", + GetLastError() + ); + return std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + } + + let filetime_to_nanos = |ft: &FILETIME| -> u128 { + let low = ft.dwLowDateTime as u64; + let high = ft.dwHighDateTime as u64; + let intervals = (high << 32) | low; + let nanos_since_1601 = intervals as u128 * 100; + nanos_since_1601.saturating_sub(11644473600_000_000_000u128) + }; + + filetime_to_nanos(&creation_time) + } +} + +#[cfg(windows)] +fn is_pid_alive(pid: u32, file_nanos: u128) -> bool { + use windows_sys::Win32::Foundation::{ + CloseHandle, GetLastError, ERROR_INVALID_PARAMETER, FILETIME, + }; + use windows_sys::Win32::System::Threading::{ + GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if handle.is_null() { + let err = GetLastError(); + if err == ERROR_INVALID_PARAMETER { + return false; + } + return true; + } + + let mut creation_time = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let mut exit_time = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let mut kernel_time = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let mut user_time = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + + let result = GetProcessTimes( + handle, + &mut creation_time, + &mut exit_time, + &mut kernel_time, + &mut user_time, + ); + + CloseHandle(handle); + + if result == 0 { + return true; + } + + let filetime_to_nanos = |ft: &FILETIME| -> u128 { + let low = ft.dwLowDateTime as u64; + let high = ft.dwHighDateTime as u64; + let intervals = (high << 32) | low; + let nanos_since_1601 = intervals as u128 * 100; + nanos_since_1601.saturating_sub(11644473600_000_000_000u128) + }; + + let creation_nanos = filetime_to_nanos(&creation_time); + + // Exact match: the filename stores the process creation time, so a + // living process with the same PID must have the exact same creation + // time. Any mismatch means the PID has been reused by a different + // process. + creation_nanos == file_nanos + } +} + +#[cfg(unix)] +fn is_pid_alive(pid: u32, _file_nanos: u128) -> bool { + unsafe { + let result = libc::kill(pid as i32, 0); + if result == 0 { + return true; + } + let err = std::io::Error::last_os_error().raw_os_error().unwrap_or(0); + // EPERM means the process exists but we lack permission to signal it. + // This branch is confirmed by code review; direct unit-testing would + // require a process owned by another user, which is not feasible in + // a standard test environment. + err == libc::EPERM as i32 + } +} + +#[cfg(not(any(windows, unix)))] +fn is_pid_alive(_pid: u32, _file_nanos: u128) -> bool { + true +} + +fn remove_entry(path: &Path) -> Result<(), std::io::Error> { + if path.is_dir() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + use tempfile::TempDir; + + #[test] + fn parse_claude_filename() { + let info = parse_cc_switch_name( + "cc-switch-claude-demo-12345-1714137600000000000.json", + PathBuf::from("/tmp/test"), + ) + .unwrap(); + assert_eq!(info.pid, 12345); + assert_eq!(info.nanos, 1714137600000000000); + } + + #[test] + fn parse_codex_dirname() { + let info = parse_cc_switch_name( + "cc-switch-codex-demo-12345-1714137600000000000", + PathBuf::from("/tmp/test"), + ) + .unwrap(); + assert_eq!(info.pid, 12345); + assert_eq!(info.nanos, 1714137600000000000); + } + + #[test] + fn parse_rejects_non_cc_switch() { + assert!(parse_cc_switch_name("some-other-file.json", PathBuf::from("/tmp/test")).is_none()); + } + + #[test] + fn parse_rejects_other_cc_switch_prefix() { + assert!( + parse_cc_switch_name( + "cc-switch-other-app-12345-1714137600000000000.json", + PathBuf::from("/tmp/test") + ) + .is_none() + ); + } + + #[test] + fn parse_rejects_invalid_pid() { + assert!(parse_cc_switch_name( + "cc-switch-claude-demo-notapid-1714137600000000000.json", + PathBuf::from("/tmp/test") + ) + .is_none()); + } + + #[test] + fn dead_pid_triggers_clean() { + let old_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .saturating_sub(25 * 60 * 60 * 1_000_000_000u128); + let entry = TempEntryInfo { + path: PathBuf::from("/tmp/cc-switch-claude-demo-99999-0.json"), + pid: 99999, + nanos: old_nanos, + }; + assert!(should_clean(&entry)); + } + + #[test] + fn dead_pid_with_fresh_file_triggers_clean() { + // No TTL: a dead PID's file is cleaned immediately, even if just written. + let recent_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let entry = TempEntryInfo { + path: PathBuf::from("/tmp/cc-switch-claude-demo-99999-0.json"), + pid: 99999, + nanos: recent_nanos, + }; + assert!(should_clean(&entry)); + } + + #[test] + fn fresh_file_no_clean() { + #[cfg(windows)] + let recent_nanos = current_process_creation_time_nanos(); + #[cfg(not(windows))] + let recent_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let entry = TempEntryInfo { + path: PathBuf::from("/tmp/cc-switch-claude-demo-1-0.json"), + pid: std::process::id(), + nanos: recent_nanos, + }; + assert!(!should_clean(&entry)); + } + + #[cfg(unix)] + #[test] + fn alive_pid_old_file_no_clean() { + // On Windows, is_pid_alive also validates creation time, so an old + // nanos with the current PID would be treated as PID reuse and + // correctly considered dead. This test is Unix-only because kill(0) + // does not check creation time. + let old_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .saturating_sub(25 * 60 * 60 * 1_000_000_000u128); + let entry = TempEntryInfo { + path: PathBuf::from("/tmp/cc-switch-claude-demo-1-0.json"), + pid: std::process::id(), + nanos: old_nanos, + }; + assert!(!should_clean(&entry)); + } + + #[cfg(windows)] + #[test] + fn windows_pid_reuse_detected_by_creation_time() { + // Windows is_pid_alive validates creation time, so a living PID + // with a mismatched (old) nanos is treated as PID reuse and + // considered dead. This is the Windows counterpart to the + // Unix-only alive_pid_old_file_no_clean test. + let old_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .saturating_sub(25 * 60 * 60 * 1_000_000_000u128); + let entry = TempEntryInfo { + path: PathBuf::from("/tmp/cc-switch-claude-demo-1-0.json"), + pid: std::process::id(), + nanos: old_nanos, + }; + // Current process creation time != old_nanos, so PID reuse + assert!(should_clean(&entry)); + } + + #[test] + fn scan_and_clean_removes_orphaned_files() { + let temp = TempDir::new().expect("create temp dir"); + + let old_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .saturating_sub(25 * 60 * 60 * 1_000_000_000u128); + let orphan = temp + .path() + .join(format!("cc-switch-claude-demo-99999-{old_nanos}.json")); + std::fs::write(&orphan, "{}").expect("write orphan file"); + + let cleaned = scan_and_clean(temp.path()); + assert_eq!(cleaned, 1); + assert!(!orphan.exists()); + } + + #[test] + fn scan_and_clean_keeps_non_cc_switch_files() { + let temp = TempDir::new().expect("create temp dir"); + let other = temp.path().join("some-other-file.txt"); + std::fs::write(&other, "hello").expect("write other file"); + + let cleaned = scan_and_clean(temp.path()); + assert_eq!(cleaned, 0); + assert!(other.exists()); + } + + #[test] + fn scan_and_clean_removes_orphaned_dirs() { + let temp = TempDir::new().expect("create temp dir"); + + let old_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .saturating_sub(25 * 60 * 60 * 1_000_000_000u128); + let orphan = temp + .path() + .join(format!("cc-switch-codex-demo-99999-{old_nanos}")); + std::fs::create_dir(&orphan).expect("create orphan dir"); + std::fs::write(orphan.join("config.toml"), "model = \"test\"\n") + .expect("write inside orphan dir"); + + let cleaned = scan_and_clean(temp.path()); + assert_eq!(cleaned, 1); + assert!(!orphan.exists()); + } +} diff --git a/src-tauri/src/cli/tui/app.rs b/src-tauri/src/cli/tui/app.rs index b63d04a3..3b102133 100644 --- a/src-tauri/src/cli/tui/app.rs +++ b/src-tauri/src/cli/tui/app.rs @@ -44,12 +44,12 @@ pub use types::{ const PROVIDER_NOTES_MAX_CHARS: usize = 120; -#[cfg(unix)] +#[cfg(any(unix, windows))] pub(crate) fn supports_temporary_provider_launch(app_type: &AppType) -> bool { matches!(app_type, AppType::Claude | AppType::Codex) } -#[cfg(not(unix))] +#[cfg(not(any(unix, windows)))] pub(crate) fn supports_temporary_provider_launch(_app_type: &AppType) -> bool { false } diff --git a/src-tauri/src/cli/tui/app/content_entities.rs b/src-tauri/src/cli/tui/app/content_entities.rs index 21781d68..899e801a 100644 --- a/src-tauri/src/cli/tui/app/content_entities.rs +++ b/src-tauri/src/cli/tui/app/content_entities.rs @@ -495,7 +495,7 @@ mod tests { )); } - #[cfg(not(unix))] + #[cfg(not(any(unix, windows)))] #[test] fn claude_provider_o_key_is_noop_on_non_unix() { let mut app = App::new(Some(AppType::Claude)); @@ -509,7 +509,7 @@ mod tests { assert!(matches!(action, Action::None)); } - #[cfg(not(unix))] + #[cfg(not(any(unix, windows)))] #[test] fn codex_provider_o_key_is_noop_on_non_unix() { let mut app = App::new(Some(AppType::Codex)); diff --git a/src-tauri/src/cli/tui/app/tests.rs b/src-tauri/src/cli/tui/app/tests.rs index a34087dc..ac806b6d 100644 --- a/src-tauri/src/cli/tui/app/tests.rs +++ b/src-tauri/src/cli/tui/app/tests.rs @@ -3168,6 +3168,7 @@ mod tests { ); } + #[cfg(unix)] #[test] #[serial(home_settings)] fn openclaw_workspace_open_failure_is_localized() { @@ -3293,6 +3294,7 @@ mod tests { assert_eq!(editor.text(), "late content"); } + #[cfg(unix)] #[test] #[serial(home_settings)] fn openclaw_daily_memory_save_failure_is_localized() { diff --git a/src-tauri/src/cli/tui/ui/providers.rs b/src-tauri/src/cli/tui/ui/providers.rs index 3e234796..1ed4ee7e 100644 --- a/src-tauri/src/cli/tui/ui/providers.rs +++ b/src-tauri/src/cli/tui/ui/providers.rs @@ -614,7 +614,7 @@ mod tests { assert!(all.contains("7d: 70%"), "{all}"); } - #[cfg(not(unix))] + #[cfg(not(any(unix, windows)))] #[test] fn claude_provider_list_key_bar_hides_launch_temp_hint_on_non_unix() { let _lock = super::super::tests::lock_env(); @@ -633,7 +633,7 @@ mod tests { ); } - #[cfg(not(unix))] + #[cfg(not(any(unix, windows)))] #[test] fn codex_provider_detail_key_bar_hides_launch_temp_hint_on_non_unix() { let _lock = super::super::tests::lock_env(); diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index af93daf5..a52bdeb7 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -83,6 +83,36 @@ impl AppError { en: en.into(), } } + + /// 警告:子进程无法分配到 Job Object(例如已经处于不允许嵌套的 Job 中),将降级回退。 + /// i18n key: `windows.job_assign_failed_fallback` + pub fn windows_job_assign_failed_fallback(reason: impl std::fmt::Display) -> Self { + Self::localized( + "windows.job_assign_failed_fallback", + format!("无法将子进程分配到 Job Object,将降级回退: {reason}"), + format!("Failed to assign child process to Job Object; falling back: {reason}"), + ) + } + + /// 错误:ResumeThread 调用失败。 + /// i18n key: `windows.resume_thread_failed` + pub fn windows_resume_thread_failed(code: u32) -> Self { + Self::localized( + "windows.resume_thread_failed", + format!("ResumeThread 调用失败,Win32 错误码: {code}"), + format!("ResumeThread failed with Win32 error code: {code}"), + ) + } + + /// 错误:CreateProcessW 调用失败。 + /// i18n key: `windows.create_process_failed` + pub fn windows_create_process_failed(code: u32) -> Self { + Self::localized( + "windows.create_process_failed", + format!("CreateProcessW 调用失败,Win32 错误码: {code}"), + format!("CreateProcessW failed with Win32 error code: {code}"), + ) + } } impl From> for AppError { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 978df67c..d4e7e167 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -15,6 +15,9 @@ fn main() { }; env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(log_level)).init(); + // Best-effort cleanup of orphaned temp credential files from previous sessions + let _ = cc_switch_lib::cli::orphan_scan::scan_and_clean(&std::env::temp_dir()); + // 执行命令 if let Err(e) = run(cli) { eprintln!("Error: {}", e); @@ -38,7 +41,7 @@ fn run(cli: Cli) -> Result<(), AppError> { Some(Commands::Skills(cmd)) => cc_switch_lib::cli::commands::skills::execute(cmd, cli.app), Some(Commands::Config(cmd)) => cc_switch_lib::cli::commands::config::execute(cmd, cli.app), Some(Commands::Proxy(cmd)) => cc_switch_lib::cli::commands::proxy::execute(cmd), - #[cfg(unix)] + #[cfg(any(unix, windows))] Some(Commands::Start(cmd)) => cc_switch_lib::cli::commands::start::execute(cmd), Some(Commands::Env(cmd)) => cc_switch_lib::cli::commands::env::execute(cmd, cli.app), Some(Commands::Update(cmd)) => cc_switch_lib::cli::commands::update::execute(cmd), From 5ef3f6c767fc8f143a15db34e7a6a6bc76c784c6 Mon Sep 17 00:00:00 2001 From: aloneatwar <2318583515@qq.com> Date: Mon, 27 Apr 2026 00:15:52 +0800 Subject: [PATCH 02/16] fix(codex-temp-launch): pass NULL lpApplicationName for cmd.exe shims CreateProcessW does not search PATH when lpApplicationName is non-NULL, so launching codex.cmd through a relative `cmd.exe` failed for shim-installed CLIs. Mirror the Claude branch by passing NULL for the application name on the .cmd/.bat path and only passing the resolved binary path for the direct-binary case. --- src-tauri/src/cli/codex_temp_launch.rs | 41 ++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/cli/codex_temp_launch.rs b/src-tauri/src/cli/codex_temp_launch.rs index 323f5c05..2cc44156 100644 --- a/src-tauri/src/cli/codex_temp_launch.rs +++ b/src-tauri/src/cli/codex_temp_launch.rs @@ -136,7 +136,22 @@ pub(crate) fn exec_prepared_codex( let env_block = build_env_block_with_override("CODEX_HOME", prepared.codex_home.as_os_str()); - let (process_handle, thread_handle) = spawn_suspended_createprocessw(&program, &args, Some(&env_block))?; + // CreateProcessW does not search PATH when lpApplicationName is non-NULL, + // so for the cmd.exe shim path (unqualified `cmd.exe`) we must pass NULL + // and let Windows resolve it from PATH. For the direct-binary path we + // pass the fully-resolved executable so the exact path is launched even + // if PATH later changes. + let exec_str = prepared.executable.to_string_lossy(); + let is_cmd_shim = + exec_str.ends_with(".cmd") || exec_str.ends_with(".bat"); + let application_name: Option<&std::path::Path> = if is_cmd_shim { + None + } else { + Some(program.as_path()) + }; + + let (process_handle, thread_handle) = + spawn_suspended_createprocessw(&program, &args, Some(&env_block), application_name)?; let job = Job::create_with_kill_on_close()?; @@ -365,11 +380,20 @@ fn spawn_suspended_createprocessw( program: &std::path::Path, args: &[OsString], env_block: Option<&[u16]>, + application_name: Option<&std::path::Path>, ) -> Result<(HANDLE, HANDLE), AppError> { - let program_wide: Vec = std::ffi::OsStr::new(program) - .encode_wide() - .chain(Some(0)) - .collect(); + // When `application_name` is `Some`, it is passed verbatim as + // `lpApplicationName` to `CreateProcessW`. In that mode CreateProcessW does + // NOT search PATH — the caller must supply a fully-resolved path. When + // `application_name` is `None` we pass NULL, which lets Windows parse the + // program name from the start of the command line and search PATH/PATHEXT + // (required for unqualified names like `cmd.exe`). + let application_name_wide: Option> = application_name.map(|p| { + std::ffi::OsStr::new(p) + .encode_wide() + .chain(Some(0)) + .collect() + }); let mut command_line = build_windows_command_line(std::ffi::OsStr::new(program), args); @@ -382,9 +406,14 @@ fn spawn_suspended_createprocessw( .map(|b| b.as_ptr() as *mut _) .unwrap_or(ptr::null_mut()); + let app_name_ptr = application_name_wide + .as_ref() + .map(|s| s.as_ptr()) + .unwrap_or(ptr::null()); + let result = unsafe { CreateProcessW( - program_wide.as_ptr(), + app_name_ptr, command_line.as_mut_ptr(), ptr::null_mut(), ptr::null_mut(), From 1a87e888d3d039c54243934554f0c8870530f94b Mon Sep 17 00:00:00 2001 From: aloneatwar <2318583515@qq.com> Date: Mon, 27 Apr 2026 00:44:44 +0800 Subject: [PATCH 03/16] fix(temp-launch): narrow trailing-backslash rejection to cmd-quoted args Previously every native arg ending with `\` was rejected on the cmd.exe /c shim path, blocking benign Windows paths like `C:\work\` or `--project-dir=C:\tmp\`. A trailing `\` only escapes a closing `"`, so the hazard is real only when the arg also forces cmd quoting. Extract `is_cmd_shim` (case-insensitive `.cmd`/`.bat`) and `arg_requires_cmd_quote` helpers in both claude and codex temp_launch.rs. Use the helper for application_name selection, and reject trailing `\` only when an arg also requires cmd quoting. Add 12 Windows-only parity tests covering helper behavior, the plain-trailing-backslash accept path, the unsafe-quote+trailing-backslash reject path, and direct-binary passthrough. --- src-tauri/src/cli/claude_temp_launch.rs | 146 ++++++++++++++++++++++-- src-tauri/src/cli/codex_temp_launch.rs | 144 +++++++++++++++++++++-- 2 files changed, 267 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/cli/claude_temp_launch.rs b/src-tauri/src/cli/claude_temp_launch.rs index f9f22b1b..88c40d56 100644 --- a/src-tauri/src/cli/claude_temp_launch.rs +++ b/src-tauri/src/cli/claude_temp_launch.rs @@ -274,13 +274,37 @@ impl Drop for Job { } } +#[cfg(windows)] +fn is_cmd_shim(path: &std::path::Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat")) + .unwrap_or(false) +} + +/// Returns true when `quote_windows_arg_for_cmd` would wrap `s` in double +/// quotes. We mirror its predicate (sans the `"` case, which is rejected by +/// the caller before this is consulted) so callers can decide whether a +/// trailing `\` is dangerous: only quoted args risk the trailing `\` +/// escaping the closing `"`. Plain Windows paths like `C:\work\` pass +/// through unquoted and are safe. +#[cfg(windows)] +fn arg_requires_cmd_quote(s: &str) -> bool { + const CMD_SPECIAL: &[char] = &['&', '|', '<', '>', '^', '%', '!', '(', ')']; + s.is_empty() + || s.contains(' ') + || s.contains('\t') + || s.contains('\n') + || s.chars().any(|c| CMD_SPECIAL.contains(&c)) +} + #[cfg(windows)] fn build_windows_cmdline( prepared: &PreparedClaudeLaunch, native_args: &[OsString], ) -> Result, AppError> { let exe_str = prepared.executable.to_string_lossy(); - let is_cmd = exe_str.ends_with(".cmd") || exe_str.ends_with(".bat"); + let is_cmd = is_cmd_shim(&prepared.executable); let mut cmdline = String::new(); @@ -294,9 +318,12 @@ fn build_windows_cmdline( // binary directly), this expansion cannot be fully avoided. Log a // warning so users are aware. // - // Additionally, cmd.exe does not treat backslash as a quote escape, - // so arguments containing a literal double quote cannot be safely - // passed through cmd.exe /c. We reject such arguments. + // cmd.exe does not treat backslash as a quote escape, so a literal + // double quote inside an arg cannot be safely escaped — reject. A + // trailing backslash only becomes unsafe when the arg itself would + // be wrapped in `"..."` by cmd quoting, because then the `\` would + // escape the closing quote. Plain paths like `C:\work\` need no + // quoting and pass through verbatim. for arg in native_args { let s = arg.to_string_lossy(); if s.contains('%') || s.contains('!') { @@ -319,15 +346,15 @@ fn build_windows_cmdline( ), )); } - if s.ends_with('\\') { + if s.ends_with('\\') && arg_requires_cmd_quote(&s) { return Err(AppError::localized( "claude.temp_launch_unsafe_cmd_trailing_backslash", format!( - "参数以反斜杠结尾,无法安全地通过 cmd.exe /c 传递: {}", + "参数同时需要 cmd.exe 加引号且以反斜杠结尾,无法安全传递: {}", s ), format!( - "Native arg ends with a backslash which cannot be safely passed through cmd.exe /c: {}", + "Native arg both requires cmd.exe quoting and ends with a backslash, which cannot be safely passed through cmd.exe /c: {}", s ), )); @@ -463,10 +490,7 @@ pub(crate) fn exec_prepared_claude( let mut cmdline_wide = build_windows_cmdline(prepared, native_args)?; - let exe_str = prepared.executable.to_string_lossy(); - let is_cmd = exe_str.ends_with(".cmd") || exe_str.ends_with(".bat"); - - let application_name_wide: Option> = if is_cmd { + let application_name_wide: Option> = if is_cmd_shim(&prepared.executable) { None } else { Some( @@ -1193,6 +1217,106 @@ mod tests { assert_eq!(quote_windows_arg_for_cmd(""), "\"\""); } + #[cfg(windows)] + #[test] + fn is_cmd_shim_matches_cmd_and_bat_case_insensitive() { + assert!(is_cmd_shim(std::path::Path::new("C:\\bin\\claude.cmd"))); + assert!(is_cmd_shim(std::path::Path::new("C:\\bin\\claude.CMD"))); + assert!(is_cmd_shim(std::path::Path::new("C:\\bin\\claude.bat"))); + assert!(is_cmd_shim(std::path::Path::new("C:\\bin\\claude.BAT"))); + assert!(!is_cmd_shim(std::path::Path::new("C:\\bin\\claude.exe"))); + assert!(!is_cmd_shim(std::path::Path::new("C:\\bin\\claude"))); + } + + #[cfg(windows)] + #[test] + fn arg_requires_cmd_quote_recognizes_unsafe_inputs() { + assert!(arg_requires_cmd_quote("")); + assert!(arg_requires_cmd_quote("foo bar")); + assert!(arg_requires_cmd_quote("foo\tbar")); + assert!(arg_requires_cmd_quote("a&b")); + assert!(arg_requires_cmd_quote("a|b")); + assert!(arg_requires_cmd_quote("a%b")); + assert!(arg_requires_cmd_quote("a!b")); + assert!(arg_requires_cmd_quote("a(b")); + assert!(arg_requires_cmd_quote("a)b")); + assert!(!arg_requires_cmd_quote("plain")); + assert!(!arg_requires_cmd_quote("C:\\work\\")); + assert!(!arg_requires_cmd_quote("--project-dir=C:\\tmp\\")); + } + + #[cfg(windows)] + #[test] + fn build_windows_cmdline_accepts_plain_trailing_backslash_paths() { + let prepared = PreparedClaudeLaunch { + executable: PathBuf::from("C:\\bin\\claude.cmd"), + settings_path: PathBuf::from("C:\\tmp\\settings.json"), + }; + let native_args = vec![ + OsString::from("C:\\work\\"), + OsString::from("--project-dir=C:\\tmp\\"), + ]; + + let cmdline = build_windows_cmdline(&prepared, &native_args) + .expect("plain trailing backslash should be allowed"); + + let cmdline_str = String::from_utf16_lossy(&cmdline); + assert!(cmdline_str.contains("cmd.exe /c")); + assert!(cmdline_str.contains("C:\\work\\")); + assert!(cmdline_str.contains("--project-dir=C:\\tmp\\")); + } + + #[cfg(windows)] + #[test] + fn build_windows_cmdline_rejects_trailing_backslash_when_quoting_required() { + let prepared = PreparedClaudeLaunch { + executable: PathBuf::from("C:\\bin\\claude.cmd"), + settings_path: PathBuf::from("C:\\tmp\\settings.json"), + }; + let native_args = vec![OsString::from("C:\\Program Files\\dir\\")]; + + let err = build_windows_cmdline(&prepared, &native_args) + .expect_err("space + trailing backslash must be rejected"); + let msg = err.to_string(); + assert!(msg.contains("backslash") || msg.contains("反斜杠")); + } + + #[cfg(windows)] + #[test] + fn build_windows_cmdline_rejects_trailing_backslash_with_special_char() { + let prepared = PreparedClaudeLaunch { + executable: PathBuf::from("C:\\bin\\claude.cmd"), + settings_path: PathBuf::from("C:\\tmp\\settings.json"), + }; + let native_args = vec![OsString::from("a&b\\")]; + + let err = build_windows_cmdline(&prepared, &native_args) + .expect_err("special char + trailing backslash must be rejected"); + let msg = err.to_string(); + assert!(msg.contains("backslash") || msg.contains("反斜杠")); + } + + #[cfg(windows)] + #[test] + fn build_windows_cmdline_passes_through_for_direct_binary() { + let prepared = PreparedClaudeLaunch { + executable: PathBuf::from("C:\\bin\\claude.exe"), + settings_path: PathBuf::from("C:\\tmp\\settings.json"), + }; + // Direct .exe path skips cmd quoting; trailing backslash is fine even + // alongside chars that would normally require quoting. + let native_args = vec![ + OsString::from("C:\\work\\"), + OsString::from("--project-dir=C:\\Program Files\\dir\\"), + ]; + + let cmdline = build_windows_cmdline(&prepared, &native_args) + .expect("direct .exe must accept trailing backslash regardless of quoting"); + let cmdline_str = String::from_utf16_lossy(&cmdline); + assert!(cmdline_str.contains("C:\\work\\")); + assert!(cmdline_str.contains("Program Files")); + } + #[test] fn prepare_launch_from_settings_writes_exact_effective_snapshot() { let temp_dir = TempDir::new().expect("create temp dir"); diff --git a/src-tauri/src/cli/codex_temp_launch.rs b/src-tauri/src/cli/codex_temp_launch.rs index 2cc44156..b0869e60 100644 --- a/src-tauri/src/cli/codex_temp_launch.rs +++ b/src-tauri/src/cli/codex_temp_launch.rs @@ -141,10 +141,7 @@ pub(crate) fn exec_prepared_codex( // and let Windows resolve it from PATH. For the direct-binary path we // pass the fully-resolved executable so the exact path is launched even // if PATH later changes. - let exec_str = prepared.executable.to_string_lossy(); - let is_cmd_shim = - exec_str.ends_with(".cmd") || exec_str.ends_with(".bat"); - let application_name: Option<&std::path::Path> = if is_cmd_shim { + let application_name: Option<&std::path::Path> = if is_cmd_shim(&prepared.executable) { None } else { Some(program.as_path()) @@ -204,13 +201,36 @@ pub(crate) fn exec_prepared_codex( )) } +#[cfg(windows)] +fn is_cmd_shim(path: &std::path::Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat")) + .unwrap_or(false) +} + +/// Returns true when `quote_windows_arg_for_cmd` would wrap `s` in double +/// quotes. We mirror its predicate (sans the `"` case, which is rejected by +/// the caller before this is consulted) so callers can decide whether a +/// trailing `\` is dangerous: only quoted args risk the trailing `\` +/// escaping the closing `"`. Plain Windows paths like `C:\work\` pass +/// through unquoted and are safe. +#[cfg(windows)] +fn arg_requires_cmd_quote(s: &str) -> bool { + const CMD_SPECIAL: &[char] = &['&', '|', '<', '>', '^', '%', '!', '(', ')']; + s.is_empty() + || s.contains(' ') + || s.contains('\t') + || s.contains('\n') + || s.chars().any(|c| CMD_SPECIAL.contains(&c)) +} + #[cfg(windows)] fn build_command_windows( prepared: &PreparedCodexLaunch, native_args: &[OsString], ) -> Result<(std::path::PathBuf, Vec), AppError> { - let exec_str = prepared.executable.to_string_lossy(); - if exec_str.ends_with(".cmd") || exec_str.ends_with(".bat") { + if is_cmd_shim(&prepared.executable) { // cmd.exe expands %VAR% and !VAR! (delayed expansion) even inside // double quotes. There is no standard escape for these in a /c // command line. Without refactoring to bypass cmd.exe /c entirely @@ -218,9 +238,12 @@ fn build_command_windows( // directly), this expansion cannot be fully avoided. Log a warning // so users are aware. // - // Additionally, cmd.exe does not treat backslash as a quote escape, - // so arguments containing a literal double quote cannot be safely - // passed through cmd.exe /c. We reject such arguments. + // cmd.exe does not treat backslash as a quote escape, so a literal + // double quote inside an arg cannot be safely escaped — reject. A + // trailing backslash only becomes unsafe when the arg itself would + // be wrapped in `"..."` by cmd quoting, because then the `\` would + // escape the closing quote. Plain paths like `C:\work\` need no + // quoting and pass through verbatim. for arg in native_args { let s = arg.to_string_lossy(); if s.contains('%') || s.contains('!') { @@ -243,15 +266,15 @@ fn build_command_windows( ), )); } - if s.ends_with('\\') { + if s.ends_with('\\') && arg_requires_cmd_quote(&s) { return Err(AppError::localized( "codex.temp_launch_unsafe_cmd_trailing_backslash", format!( - "参数以反斜杠结尾,无法安全地通过 cmd.exe /c 传递: {}", + "参数同时需要 cmd.exe 加引号且以反斜杠结尾,无法安全传递: {}", s ), format!( - "Native arg ends with a backslash which cannot be safely passed through cmd.exe /c: {}", + "Native arg both requires cmd.exe quoting and ends with a backslash, which cannot be safely passed through cmd.exe /c: {}", s ), )); @@ -920,6 +943,103 @@ mod tests { assert_eq!(quote_windows_arg("foo\nbar"), "\"foo\nbar\""); } + #[cfg(windows)] + #[test] + fn is_cmd_shim_matches_cmd_and_bat_case_insensitive() { + assert!(is_cmd_shim(std::path::Path::new("C:/tools/codex.cmd"))); + assert!(is_cmd_shim(std::path::Path::new("C:/tools/codex.CMD"))); + assert!(is_cmd_shim(std::path::Path::new("C:/tools/codex.bat"))); + assert!(is_cmd_shim(std::path::Path::new("C:/tools/codex.BaT"))); + assert!(!is_cmd_shim(std::path::Path::new("C:/tools/codex.exe"))); + assert!(!is_cmd_shim(std::path::Path::new("C:/tools/codex"))); + } + + #[cfg(windows)] + #[test] + fn arg_requires_cmd_quote_recognizes_unsafe_inputs() { + assert!(arg_requires_cmd_quote("")); + assert!(arg_requires_cmd_quote("a b")); + assert!(arg_requires_cmd_quote("a\tb")); + assert!(arg_requires_cmd_quote("a&b")); + assert!(arg_requires_cmd_quote("a%b")); + assert!(!arg_requires_cmd_quote("plain")); + assert!(!arg_requires_cmd_quote("C:\\work\\")); + assert!(!arg_requires_cmd_quote("--project-dir=C:\\tmp\\")); + } + + #[cfg(windows)] + #[test] + fn build_command_windows_accepts_plain_trailing_backslash_paths() { + let prepared = PreparedCodexLaunch { + executable: PathBuf::from("C:/tools/codex.cmd"), + codex_home: PathBuf::from("C:/tmp/cc-switch-codex-home"), + }; + let native_args = vec![ + OsString::from("--project-dir=C:\\tmp\\"), + OsString::from("C:\\work\\"), + ]; + + let (program, args) = + build_command_windows(&prepared, &native_args).expect("plain trailing backslash paths must pass"); + + assert_eq!(program, PathBuf::from("cmd.exe")); + assert!(args.iter().any(|a| a == "C:\\work\\")); + assert!(args + .iter() + .any(|a| a == "--project-dir=C:\\tmp\\")); + } + + #[cfg(windows)] + #[test] + fn build_command_windows_rejects_trailing_backslash_when_quoting_required() { + let prepared = PreparedCodexLaunch { + executable: PathBuf::from("C:/tools/codex.cmd"), + codex_home: PathBuf::from("C:/tmp/cc-switch-codex-home"), + }; + let native_args = vec![OsString::from("C:\\Program Files\\dir\\")]; + + let err = build_command_windows(&prepared, &native_args) + .expect_err("space + trailing backslash must be rejected for cmd.exe /c"); + + assert!(err + .to_string() + .contains("C:\\Program Files\\dir\\")); + } + + #[cfg(windows)] + #[test] + fn build_command_windows_rejects_trailing_backslash_with_special_char() { + let prepared = PreparedCodexLaunch { + executable: PathBuf::from("C:/tools/codex.cmd"), + codex_home: PathBuf::from("C:/tmp/cc-switch-codex-home"), + }; + let native_args = vec![OsString::from("a&b\\")]; + + let err = build_command_windows(&prepared, &native_args) + .expect_err("cmd-special char + trailing backslash must be rejected for cmd.exe /c"); + + assert!(err.to_string().contains("a&b\\")); + } + + #[cfg(windows)] + #[test] + fn build_command_windows_passes_through_for_direct_binary() { + let prepared = PreparedCodexLaunch { + executable: PathBuf::from("C:/tools/codex.exe"), + codex_home: PathBuf::from("C:/tmp/cc-switch-codex-home"), + }; + let native_args = vec![ + OsString::from("C:\\Program Files\\dir\\"), + OsString::from("--project-dir=C:\\tmp\\"), + ]; + + let (program, args) = build_command_windows(&prepared, &native_args) + .expect("direct-binary path must not apply cmd.exe restrictions"); + + assert_eq!(program, PathBuf::from("C:/tools/codex.exe")); + assert_eq!(args, native_args); + } + #[cfg(unix)] fn write_test_executable(temp_dir: &TempDir, name: &str, body: &str) -> PathBuf { let path = temp_dir.path().join(name); From 7f6b63bf6e6419b07707096f3707b7111c7550db Mon Sep 17 00:00:00 2001 From: aloneatwar <2318583515@qq.com> Date: Mon, 27 Apr 2026 00:55:01 +0800 Subject: [PATCH 04/16] chore(temp-launch): remove dead helper and gate Unix-only imports Drop the now-unused `pub(crate) fn build_command_windows` from claude_temp_launch.rs. The live Windows path goes through `build_windows_cmdline` + `is_cmd_shim`, so the case-sensitive `ends_with(".cmd")` helper was just a parity hazard for future readers. Gate `use std::time::{SystemTime, UNIX_EPOCH};` with `#[cfg(not(windows))]` in both temp_launch.rs files. On Windows the timestamp comes from `current_process_creation_time_nanos()`, so the imports were unused. --- src-tauri/src/cli/claude_temp_launch.rs | 26 +------------------------ src-tauri/src/cli/codex_temp_launch.rs | 1 + 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/cli/claude_temp_launch.rs b/src-tauri/src/cli/claude_temp_launch.rs index 88c40d56..449da26a 100644 --- a/src-tauri/src/cli/claude_temp_launch.rs +++ b/src-tauri/src/cli/claude_temp_launch.rs @@ -2,6 +2,7 @@ use std::ffi::OsString; use std::fs::{self, File, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; +#[cfg(not(windows))] use std::time::{SystemTime, UNIX_EPOCH}; #[cfg(windows)] @@ -135,31 +136,6 @@ pub(crate) fn exec_prepared_claude( )) } -#[cfg(windows)] -pub(crate) fn build_command_windows( - prepared: &PreparedClaudeLaunch, - native_args: &[OsString], -) -> std::process::Command { - let exe_str = prepared.executable.to_string_lossy(); - let is_cmd = exe_str.ends_with(".cmd") || exe_str.ends_with(".bat"); - - if is_cmd { - let mut cmd = std::process::Command::new("cmd.exe"); - cmd.arg("/c"); - cmd.arg(&prepared.executable); - cmd.arg("--settings"); - cmd.arg(&prepared.settings_path); - cmd.args(native_args); - cmd - } else { - let mut cmd = std::process::Command::new(&prepared.executable); - cmd.arg("--settings"); - cmd.arg(&prepared.settings_path); - cmd.args(native_args); - cmd - } -} - #[cfg(windows)] struct ScopedConsoleCtrlHandler; diff --git a/src-tauri/src/cli/codex_temp_launch.rs b/src-tauri/src/cli/codex_temp_launch.rs index b0869e60..ff27b8c4 100644 --- a/src-tauri/src/cli/codex_temp_launch.rs +++ b/src-tauri/src/cli/codex_temp_launch.rs @@ -2,6 +2,7 @@ use std::ffi::OsString; use std::fs::{self, File, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; +#[cfg(not(windows))] use std::time::{SystemTime, UNIX_EPOCH}; use crate::codex_config::validate_config_toml; From 78724f5d0b0047af3d95cf54d1c97d6ae0aaf80d Mon Sep 17 00:00:00 2001 From: aloneatwar <2318583515@qq.com> Date: Mon, 27 Apr 2026 01:03:50 +0800 Subject: [PATCH 05/16] fix(temp-launch): add per-launch sequence to avoid filename collisions On Windows the temp filename used `process_creation_time` as the timestamp component, which is constant for the same process. Same-provider launches within one cc-switch process therefore stably collided on the temp file or codex_home directory name. Insert an 8-hex `LAUNCH_SEQ` atomic counter between provider and pid in the filename / dirname: cc-switch-claude-{provider}-{seq}-{pid}-{timestamp}.json cc-switch-codex-{provider}-{seq}-{pid}-{timestamp} Pid and timestamp remain the last two `-`-separated segments, so the existing `orphan_scan::parse_cc_switch_name` parser keeps working without changes. Add tests: - `write_temp_settings_file_uses_unique_filename_per_call` (claude) verifies two consecutive calls produce different paths. - `parse_*_with_launch_seq_segment` (orphan_scan) verify the parser still extracts pid + nanos from the longer format. --- src-tauri/src/cli/claude_temp_launch.rs | 33 ++++++++++++++++++++++++- src-tauri/src/cli/codex_temp_launch.rs | 5 +++- src-tauri/src/cli/orphan_scan.rs | 24 ++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/cli/claude_temp_launch.rs b/src-tauri/src/cli/claude_temp_launch.rs index 449da26a..9b3d71d4 100644 --- a/src-tauri/src/cli/claude_temp_launch.rs +++ b/src-tauri/src/cli/claude_temp_launch.rs @@ -2,6 +2,7 @@ use std::ffi::OsString; use std::fs::{self, File, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(not(windows))] use std::time::{SystemTime, UNIX_EPOCH}; @@ -606,8 +607,10 @@ where .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos(); + static LAUNCH_SEQ: AtomicU64 = AtomicU64::new(0); + let seq = LAUNCH_SEQ.fetch_add(1, Ordering::Relaxed); let filename = format!( - "cc-switch-claude-{}-{}-{timestamp}.json", + "cc-switch-claude-{}-{seq:08x}-{}-{timestamp}.json", sanitize_filename_fragment(provider_id), std::process::id() ); @@ -981,6 +984,34 @@ mod tests { ); } + #[test] + fn write_temp_settings_file_uses_unique_filename_per_call() { + let temp_dir = TempDir::new().expect("create temp dir"); + let settings = json!({ + "env": { "ANTHROPIC_AUTH_TOKEN": "sk-demo" } + }); + + let path1 = write_temp_settings_file_with(temp_dir.path(), "demo", &settings, |_| Ok(())) + .expect("first write must succeed"); + let path2 = write_temp_settings_file_with(temp_dir.path(), "demo", &settings, |_| Ok(())) + .expect("second write must succeed"); + + assert_ne!( + path1, path2, + "two consecutive launches in the same process must not collide on filename" + ); + let name1 = path1 + .file_name() + .and_then(|n| n.to_str()) + .expect("path1 has utf8 filename"); + let name2 = path2 + .file_name() + .and_then(|n| n.to_str()) + .expect("path2 has utf8 filename"); + assert!(name1.starts_with("cc-switch-claude-demo-")); + assert!(name2.starts_with("cc-switch-claude-demo-")); + } + #[test] fn prepare_launch_writes_claude_env_settings_file() { let temp_dir = TempDir::new().expect("create temp dir"); diff --git a/src-tauri/src/cli/codex_temp_launch.rs b/src-tauri/src/cli/codex_temp_launch.rs index ff27b8c4..6f21b56e 100644 --- a/src-tauri/src/cli/codex_temp_launch.rs +++ b/src-tauri/src/cli/codex_temp_launch.rs @@ -2,6 +2,7 @@ use std::ffi::OsString; use std::fs::{self, File, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(not(windows))] use std::time::{SystemTime, UNIX_EPOCH}; @@ -663,8 +664,10 @@ where .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos(); + static LAUNCH_SEQ: AtomicU64 = AtomicU64::new(0); + let seq = LAUNCH_SEQ.fetch_add(1, Ordering::Relaxed); let dir_name = format!( - "cc-switch-codex-{}-{}-{timestamp}", + "cc-switch-codex-{}-{seq:08x}-{}-{timestamp}", sanitize_filename_fragment(&provider.id), std::process::id() ); diff --git a/src-tauri/src/cli/orphan_scan.rs b/src-tauri/src/cli/orphan_scan.rs index c4231972..a6851b9e 100644 --- a/src-tauri/src/cli/orphan_scan.rs +++ b/src-tauri/src/cli/orphan_scan.rs @@ -260,6 +260,30 @@ mod tests { assert_eq!(info.nanos, 1714137600000000000); } + #[test] + fn parse_claude_filename_with_launch_seq_segment() { + // New format inserts an 8-hex launch-seq segment before pid; parser + // must still extract pid (second-to-last) and nanos (last). + let info = parse_cc_switch_name( + "cc-switch-claude-demo-0000002a-12345-1714137600000000000.json", + PathBuf::from("/tmp/test"), + ) + .unwrap(); + assert_eq!(info.pid, 12345); + assert_eq!(info.nanos, 1714137600000000000); + } + + #[test] + fn parse_codex_dirname_with_launch_seq_segment() { + let info = parse_cc_switch_name( + "cc-switch-codex-demo-0000002a-12345-1714137600000000000", + PathBuf::from("/tmp/test"), + ) + .unwrap(); + assert_eq!(info.pid, 12345); + assert_eq!(info.nanos, 1714137600000000000); + } + #[test] fn parse_rejects_non_cc_switch() { assert!(parse_cc_switch_name("some-other-file.json", PathBuf::from("/tmp/test")).is_none()); From 5c3c5dff8bbcab146a675b956cac5d78c2994b1e Mon Sep 17 00:00:00 2001 From: aloneatwar <2318583515@qq.com> Date: Mon, 27 Apr 2026 01:12:42 +0800 Subject: [PATCH 06/16] fix(codex-temp-launch): cleanup suspended child when Job creation fails If `Job::create_with_kill_on_close()` failed, the suspended child process was leaking (handles + zombie suspended process). Mirror the defensive cleanup pattern already used in claude_temp_launch.rs: TerminateProcess + CloseHandle on both handles before returning the error. --- src-tauri/src/cli/codex_temp_launch.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/cli/codex_temp_launch.rs b/src-tauri/src/cli/codex_temp_launch.rs index 6f21b56e..d9563690 100644 --- a/src-tauri/src/cli/codex_temp_launch.rs +++ b/src-tauri/src/cli/codex_temp_launch.rs @@ -152,7 +152,17 @@ pub(crate) fn exec_prepared_codex( let (process_handle, thread_handle) = spawn_suspended_createprocessw(&program, &args, Some(&env_block), application_name)?; - let job = Job::create_with_kill_on_close()?; + let job = match Job::create_with_kill_on_close() { + Ok(job) => job, + Err(e) => { + unsafe { + let _ = TerminateProcess(process_handle, 1); + CloseHandle(thread_handle); + CloseHandle(process_handle); + } + return Err(e); + } + }; if let Err(e) = job.try_assign(process_handle) { log::warn!("{}", AppError::windows_job_assign_failed_fallback(&e)); From 1fd6f4ed7a0c12f00143e99027a5639c6dade383 Mon Sep 17 00:00:00 2001 From: aloneatwar <2318583515@qq.com> Date: Mon, 27 Apr 2026 01:19:55 +0800 Subject: [PATCH 07/16] fix(claude-temp-launch): use TerminateProcess in ResumeThread failure path When `ResumeThread` failed, `job.terminate()` was used to kill the suspended child. But `try_assign` earlier only warns-and-continues on failure; if the process never made it into the job, `TerminateJobObject` would do nothing and the suspended child would leak. Replace with explicit `TerminateProcess(h_process, 1)` so the cleanup is correct in both single- and double-failure paths, matching the pattern in codex_temp_launch.rs. Also drop the now-unused `Job::terminate` helper. --- src-tauri/src/cli/claude_temp_launch.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/cli/claude_temp_launch.rs b/src-tauri/src/cli/claude_temp_launch.rs index 9b3d71d4..84925cce 100644 --- a/src-tauri/src/cli/claude_temp_launch.rs +++ b/src-tauri/src/cli/claude_temp_launch.rs @@ -234,11 +234,6 @@ impl Job { Ok(()) } } - - unsafe fn terminate(&self) { - use windows_sys::Win32::System::JobObjects::TerminateJobObject; - let _ = TerminateJobObject(self.handle, 1); - } } #[cfg(windows)] @@ -536,7 +531,11 @@ pub(crate) fn exec_prepared_claude( let resume_result = unsafe { ResumeThread(h_thread) }; if resume_result == u32::MAX { unsafe { - job.terminate(); + // Use explicit TerminateProcess instead of job.terminate(): if the + // earlier try_assign warned-and-continued, the process is not in + // the job, so TerminateJobObject would do nothing and the + // suspended child would leak. TerminateProcess kills it directly. + let _ = windows_sys::Win32::System::Threading::TerminateProcess(h_process, 1); CloseHandle(h_thread); CloseHandle(h_process); } From ee2b0f3db372cf8b7bac1d6efe5ef78e64cdce75 Mon Sep 17 00:00:00 2001 From: aloneatwar <2318583515@qq.com> Date: Mon, 27 Apr 2026 02:36:34 +0800 Subject: [PATCH 08/16] refactor(windows): extract shared Windows temp launch utilities Extract duplicated Windows logic from claude_temp_launch.rs and codex_temp_launch.rs into a new windows_temp_launch.rs module: - is_cmd_shim, arg_requires_cmd_quote - quote_windows_arg, quote_windows_arg_for_cmd - build_windows_command_line, build_env_block_with_override - ScopedConsoleCtrlHandler, Job - spawn_suspended_createprocessw, wait_for_child - restrict_to_owner, create_secret_temp_file Also add AppError constructors for Windows Job Object failures. Add an automated Windows smoke test covering: - spawn suspended child via CreateProcessW - Job Object creation and assignment - ResumeThread + wait + exit code propagation Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/cli/claude_temp_launch.rs | 715 +++-------------------- src-tauri/src/cli/codex_temp_launch.rs | 568 +----------------- src-tauri/src/cli/mod.rs | 2 + src-tauri/src/cli/windows_temp_launch.rs | 619 ++++++++++++++++++++ src-tauri/src/error.rs | 20 + 5 files changed, 723 insertions(+), 1201 deletions(-) create mode 100644 src-tauri/src/cli/windows_temp_launch.rs diff --git a/src-tauri/src/cli/claude_temp_launch.rs b/src-tauri/src/cli/claude_temp_launch.rs index 84925cce..720ae3a1 100644 --- a/src-tauri/src/cli/claude_temp_launch.rs +++ b/src-tauri/src/cli/claude_temp_launch.rs @@ -1,14 +1,11 @@ use std::ffi::OsString; -use std::fs::{self, File, OpenOptions}; +use std::fs::{self, File}; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(not(windows))] use std::time::{SystemTime, UNIX_EPOCH}; -#[cfg(windows)] -use std::os::windows::ffi::OsStrExt; - use crate::error::AppError; use crate::provider::Provider; use crate::services::provider::ProviderService; @@ -138,164 +135,81 @@ pub(crate) fn exec_prepared_claude( } #[cfg(windows)] -struct ScopedConsoleCtrlHandler; - -#[cfg(windows)] -impl ScopedConsoleCtrlHandler { - fn install() -> Result { - unsafe { - let result = windows_sys::Win32::System::Console::SetConsoleCtrlHandler( - Some(ctrl_handler_swallow), - 1, - ); - if result == 0 { - return Err(AppError::localized( - "windows.console_ctrl_handler_failed", - "安装控制台 Ctrl+C 处理器失败".to_string(), - "Failed to install console Ctrl+C handler.".to_string(), - )); - } - } - Ok(ScopedConsoleCtrlHandler) - } -} - -#[cfg(windows)] -impl Drop for ScopedConsoleCtrlHandler { - fn drop(&mut self) { - unsafe { - let _ = windows_sys::Win32::System::Console::SetConsoleCtrlHandler( - Some(ctrl_handler_swallow), - 0, - ); - } - } -} +pub(crate) fn exec_prepared_claude( + prepared: &PreparedClaudeLaunch, + native_args: &[OsString], +) -> Result<(), AppError> { + use crate::cli::windows_temp_launch::{ + Job, ScopedConsoleCtrlHandler, spawn_suspended_createprocessw, wait_for_child, + }; + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::ResumeThread; -#[cfg(windows)] -unsafe extern "system" fn ctrl_handler_swallow(_dw_ctrl_type: u32) -> i32 { - 1 -} + let _ctrl_guard = ScopedConsoleCtrlHandler::install()?; -#[cfg(windows)] -struct Job { - handle: windows_sys::Win32::Foundation::HANDLE, -} + let (program, args, application_name) = build_claude_command_windows(prepared, native_args)?; -#[cfg(windows)] -impl Job { - unsafe fn create_with_kill_on_close() -> Result { - use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; - use windows_sys::Win32::System::JobObjects::{ - CreateJobObjectW, JobObjectExtendedLimitInformation, SetInformationJobObject, - JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, - }; + let (h_process, h_thread) = + spawn_suspended_createprocessw(&program, &args, None, application_name.as_deref())?; - let handle = CreateJobObjectW(std::ptr::null(), std::ptr::null()); - if handle.is_null() || handle == INVALID_HANDLE_VALUE { - return Err(AppError::localized( - "windows.create_job_object_failed", - "创建 Job Object 失败".to_string(), - "Failed to create Job Object.".to_string(), - )); + let job = match Job::create_with_kill_on_close() { + Ok(job) => job, + Err(e) => { + unsafe { + let _ = windows_sys::Win32::System::Threading::TerminateProcess(h_process, 1); + CloseHandle(h_thread); + CloseHandle(h_process); + } + return Err(e); } + }; - let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); - info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - - let result = SetInformationJobObject( - handle, - JobObjectExtendedLimitInformation, - &info as *const _ as *const _, - std::mem::size_of::() as u32, - ); + if let Err(e) = job.try_assign(h_process) { + log::warn!(target: "windows.job_assign_failed_fallback", "{}", e); + } - if result == 0 { - CloseHandle(handle); - return Err(AppError::localized( - "windows.set_job_info_failed", - "设置 Job Object 信息失败".to_string(), - "Failed to set Job Object information.".to_string(), - )); + let resume_result = unsafe { ResumeThread(h_thread) }; + if resume_result == u32::MAX { + unsafe { + let _ = windows_sys::Win32::System::Threading::TerminateProcess(h_process, 1); + CloseHandle(h_thread); + CloseHandle(h_process); } - - Ok(Job { handle }) + return Err(AppError::windows_resume_thread_failed(unsafe { + windows_sys::Win32::Foundation::GetLastError() + })); } - unsafe fn try_assign( - &self, - process: windows_sys::Win32::Foundation::HANDLE, - ) -> Result<(), std::io::Error> { - use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject; - let result = AssignProcessToJobObject(self.handle, process); - if result == 0 { - Err(std::io::Error::last_os_error()) - } else { - Ok(()) - } + unsafe { + CloseHandle(h_thread); } -} -#[cfg(windows)] -impl Drop for Job { - fn drop(&mut self) { - use windows_sys::Win32::Foundation::CloseHandle; - unsafe { - let _ = CloseHandle(self.handle); - } + let exit_code = wait_for_child(h_process)?; + unsafe { + CloseHandle(h_process); } -} -#[cfg(windows)] -fn is_cmd_shim(path: &std::path::Path) -> bool { - path.extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat")) - .unwrap_or(false) -} + if exit_code != 0 { + return Err(AppError::Message(format!( + "Claude exited with code {}", + exit_code + ))); + } -/// Returns true when `quote_windows_arg_for_cmd` would wrap `s` in double -/// quotes. We mirror its predicate (sans the `"` case, which is rejected by -/// the caller before this is consulted) so callers can decide whether a -/// trailing `\` is dangerous: only quoted args risk the trailing `\` -/// escaping the closing `"`. Plain Windows paths like `C:\work\` pass -/// through unquoted and are safe. -#[cfg(windows)] -fn arg_requires_cmd_quote(s: &str) -> bool { - const CMD_SPECIAL: &[char] = &['&', '|', '<', '>', '^', '%', '!', '(', ')']; - s.is_empty() - || s.contains(' ') - || s.contains('\t') - || s.contains('\n') - || s.chars().any(|c| CMD_SPECIAL.contains(&c)) + Ok(()) } #[cfg(windows)] -fn build_windows_cmdline( +fn build_claude_command_windows( prepared: &PreparedClaudeLaunch, native_args: &[OsString], -) -> Result, AppError> { +) -> Result<(PathBuf, Vec, Option), AppError> { + use crate::cli::windows_temp_launch::{arg_requires_cmd_quote, is_cmd_shim}; + let exe_str = prepared.executable.to_string_lossy(); let is_cmd = is_cmd_shim(&prepared.executable); - let mut cmdline = String::new(); - if is_cmd { - // cmd.exe expands %VAR% and !VAR! (delayed expansion) even inside - // double quotes. There is no standard escape for these in a /c - // command line. We quote the argument to prevent command injection - // from & | < > ^ ( ), but % and ! remain a best-effort limitation - // of the cmd.exe shell. Without refactoring to bypass cmd.exe /c - // entirely (e.g. parse the .cmd shim and invoke the underlying - // binary directly), this expansion cannot be fully avoided. Log a - // warning so users are aware. - // - // cmd.exe does not treat backslash as a quote escape, so a literal - // double quote inside an arg cannot be safely escaped — reject. A - // trailing backslash only becomes unsafe when the arg itself would - // be wrapped in `"..."` by cmd quoting, because then the `\` would - // escape the closing quote. Plain paths like `C:\work\` need no - // quoting and pass through verbatim. for arg in native_args { let s = arg.to_string_lossy(); if s.contains('%') || s.contains('!') { @@ -332,254 +246,22 @@ fn build_windows_cmdline( )); } } - cmdline.push_str("cmd.exe /c "); - cmdline.push_str("e_windows_arg_for_cmd(&exe_str)); - cmdline.push_str(" --settings "); - cmdline.push_str("e_windows_arg_for_cmd( - &prepared.settings_path.to_string_lossy(), - )); - for arg in native_args { - cmdline.push(' '); - cmdline.push_str("e_windows_arg_for_cmd(&arg.to_string_lossy())); - } - } else { - cmdline.push_str("e_windows_arg(&exe_str)); - cmdline.push_str(" --settings "); - cmdline.push_str("e_windows_arg( - &prepared.settings_path.to_string_lossy(), - )); - for arg in native_args { - cmdline.push(' '); - cmdline.push_str("e_windows_arg(&arg.to_string_lossy())); - } - } - - Ok(cmdline.encode_utf16().chain(std::iter::once(0)).collect()) -} - -#[cfg(windows)] -fn quote_windows_arg(arg: &str) -> String { - if !arg.is_empty() - && !arg.contains(' ') - && !arg.contains('\t') - && !arg.contains('\n') - && !arg.contains('"') - { - return arg.to_string(); - } - - let mut result = String::with_capacity(arg.len() + 2); - result.push('"'); - - let mut chars = arg.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '"' { - result.push('\\'); - result.push('"'); - } else if ch == '\\' { - let mut count = 1; - while chars.peek() == Some(&'\\') { - count += 1; - chars.next(); - } - if chars.peek() == Some(&'"') || chars.peek().is_none() { - // Double backslashes when followed by a quote or end of string - for _ in 0..count * 2 { - result.push('\\'); - } - } else { - for _ in 0..count { - result.push('\\'); - } - } - } else { - result.push(ch); - } - } - - result.push('"'); - result -} - -#[cfg(windows)] -fn quote_windows_arg_for_cmd(arg: &str) -> String { - const CMD_SPECIAL: &[char] = &['&', '|', '<', '>', '^', '%', '!', '(', ')']; - let needs_quote = arg.is_empty() - || arg.contains(' ') - || arg.contains('\t') - || arg.contains('\n') - || arg.contains('"') - || arg.chars().any(|c| CMD_SPECIAL.contains(&c)); - - if !needs_quote { - return arg.to_string(); - } - - let mut result = String::with_capacity(arg.len() + 2); - result.push('"'); - - let mut chars = arg.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '"' { - result.push('\\'); - result.push('"'); - } else if ch == '\\' { - let mut count = 1; - while chars.peek() == Some(&'\\') { - count += 1; - chars.next(); - } - if chars.peek() == Some(&'"') || chars.peek().is_none() { - for _ in 0..count * 2 { - result.push('\\'); - } - } else { - for _ in 0..count { - result.push('\\'); - } - } - } else { - result.push(ch); - } - } - - result.push('"'); - result -} - -#[cfg(windows)] -pub(crate) fn exec_prepared_claude( - prepared: &PreparedClaudeLaunch, - native_args: &[OsString], -) -> Result<(), AppError> { - use windows_sys::Win32::Foundation::{CloseHandle, FALSE}; - use windows_sys::Win32::System::Threading::{ - CreateProcessW, GetExitCodeProcess, ResumeThread, WaitForSingleObject, INFINITE, - PROCESS_INFORMATION, STARTUPINFOW, - }; - - let _ctrl_guard = ScopedConsoleCtrlHandler::install()?; - - let mut cmdline_wide = build_windows_cmdline(prepared, native_args)?; - - let application_name_wide: Option> = if is_cmd_shim(&prepared.executable) { - None + let mut args = vec![ + OsString::from("/c"), + OsString::from(exe_str.as_ref()), + OsString::from("--settings"), + OsString::from(&prepared.settings_path), + ]; + args.extend_from_slice(native_args); + Ok((PathBuf::from("cmd.exe"), args, None)) } else { - Some( - std::ffi::OsStr::new(&*prepared.executable) - .encode_wide() - .chain(std::iter::once(0)) - .collect(), - ) - }; - - let mut startup_info: STARTUPINFOW = unsafe { std::mem::zeroed() }; - startup_info.cb = std::mem::size_of::() as u32; - - let mut process_info: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; - - let app_name_ptr = application_name_wide - .as_ref() - .map(|s| s.as_ptr()) - .unwrap_or(std::ptr::null()); - - let create_result = unsafe { - CreateProcessW( - app_name_ptr, - cmdline_wide.as_mut_ptr(), - std::ptr::null(), - std::ptr::null(), - FALSE, - 0x00000004, - std::ptr::null(), - std::ptr::null(), - &startup_info, - &mut process_info, - ) - }; - - if create_result == 0 { - return Err(AppError::localized( - "windows.create_process_failed", - "创建进程失败".to_string(), - format!( - "Failed to create process: {}", - std::io::Error::last_os_error() - ), - )); - } - - let h_process = process_info.hProcess; - let h_thread = process_info.hThread; - - let job = match unsafe { Job::create_with_kill_on_close() } { - Ok(job) => job, - Err(e) => { - unsafe { - let _ = windows_sys::Win32::System::Threading::TerminateProcess(h_process, 1); - CloseHandle(h_thread); - CloseHandle(h_process); - } - return Err(e); - } - }; - - if let Err(e) = unsafe { job.try_assign(h_process) } { - log::warn!(target: "windows.job_assign_failed_fallback", "{}", e); - } - - let resume_result = unsafe { ResumeThread(h_thread) }; - if resume_result == u32::MAX { - unsafe { - // Use explicit TerminateProcess instead of job.terminate(): if the - // earlier try_assign warned-and-continued, the process is not in - // the job, so TerminateJobObject would do nothing and the - // suspended child would leak. TerminateProcess kills it directly. - let _ = windows_sys::Win32::System::Threading::TerminateProcess(h_process, 1); - CloseHandle(h_thread); - CloseHandle(h_process); - } - return Err(AppError::localized( - "windows.resume_thread_failed", - "恢复线程失败".to_string(), - format!( - "Failed to resume thread: {}", - std::io::Error::last_os_error() - ), - )); - } - - unsafe { - WaitForSingleObject(h_process, INFINITE); - } - - let mut exit_code: u32 = 0; - let get_exit_result = unsafe { GetExitCodeProcess(h_process, &mut exit_code) }; - - unsafe { - CloseHandle(h_thread); - CloseHandle(h_process); - } - - if get_exit_result == 0 { - return Err(AppError::localized( - "windows.get_exit_code_failed", - "获取进程退出码失败".to_string(), - format!( - "Failed to get exit code: {}", - std::io::Error::last_os_error() - ), - )); - } - - if exit_code != 0 { - return Err(AppError::Message(format!( - "Claude exited with code {}", - exit_code - ))); + let mut args = vec![ + OsString::from("--settings"), + OsString::from(&prepared.settings_path), + ]; + args.extend_from_slice(native_args); + Ok((prepared.executable.clone(), args, Some(prepared.executable.clone()))) } - - Ok(()) } fn write_temp_settings_file( @@ -657,7 +339,7 @@ fn finalize_temp_settings_file(path: &Path) -> Result<(), AppError> { fn finalize_temp_settings_file(path: &Path) -> Result<(), AppError> { #[cfg(windows)] { - restrict_to_owner(path, false)?; + crate::cli::windows_temp_launch::restrict_to_owner(path, false)?; } Ok(()) } @@ -676,123 +358,7 @@ fn create_secret_temp_file(path: &Path) -> Result { #[cfg(not(unix))] fn create_secret_temp_file(path: &Path) -> Result { - let file = OpenOptions::new() - .write(true) - .create_new(true) - .open(path) - .map_err(|err| AppError::io(path, err))?; - #[cfg(windows)] - { - restrict_to_owner(path, false)?; - } - Ok(file) -} - -#[cfg(windows)] -fn restrict_to_owner(path: &Path, inherit: bool) -> Result<(), AppError> { - use std::ffi::OsStr; - use std::os::windows::ffi::OsStrExt; - use windows_sys::Win32::Foundation::{CloseHandle, ERROR_SUCCESS, HANDLE}; - use windows_sys::Win32::Security::Authorization::{ - SetNamedSecurityInfoW, SE_FILE_OBJECT, - }; - use windows_sys::Win32::Security::{ - ACL, AddAccessAllowedAceEx, DACL_SECURITY_INFORMATION, - GetLengthSid, GetTokenInformation, InitializeAcl, - PROTECTED_DACL_SECURITY_INFORMATION, - TOKEN_QUERY, TOKEN_USER, TokenUser, - }; - use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; - - const NO_INHERITANCE: u32 = 0; - const OBJECT_INHERIT_ACE: u32 = 0x1; - const CONTAINER_INHERIT_ACE: u32 = 0x2; - const FILE_ALL_ACCESS: u32 = 0x1F01FF; - const ACL_REVISION: u32 = 2; - - // Open current process token to get the user SID - let mut token: HANDLE = std::ptr::null_mut(); - let result = unsafe { - OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) - }; - if result == 0 { - return Err(AppError::io(path, std::io::Error::last_os_error())); - } - - // Get token user info (first call to get size) - let mut size = 0u32; - unsafe { - GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut size); - } - - let mut buffer = vec![0u8; size as usize]; - let result = unsafe { - GetTokenInformation( - token, - TokenUser, - buffer.as_mut_ptr() as *mut _, - size, - &mut size, - ) - }; - if result == 0 { - unsafe { CloseHandle(token) }; - return Err(AppError::io(path, std::io::Error::last_os_error())); - } - - let token_user = unsafe { &*(buffer.as_ptr() as *const TOKEN_USER) }; - let user_sid = token_user.User.Sid; - - unsafe { CloseHandle(token) }; - - let sid_len = unsafe { GetLengthSid(user_sid) }; - - // ACL size = ACL header + ACCESS_ALLOWED_ACE without SidStart + SID length - // ACL header = 8 bytes, ACE header+Mask = 8 bytes, SidStart = 4 bytes - let acl_size = (std::mem::size_of::() + 8 + sid_len as usize) as u32; - let mut acl_buffer = vec![0u8; acl_size as usize]; - let acl = acl_buffer.as_mut_ptr() as *mut ACL; - - let result = unsafe { InitializeAcl(acl, acl_size, ACL_REVISION) }; - if result == 0 { - return Err(AppError::io(path, std::io::Error::last_os_error())); - } - - let ace_flags = if inherit { - OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE - } else { - NO_INHERITANCE - }; - - let result = unsafe { - AddAccessAllowedAceEx(acl, ACL_REVISION, ace_flags, FILE_ALL_ACCESS, user_sid) - }; - if result == 0 { - return Err(AppError::io(path, std::io::Error::last_os_error())); - } - - let path_wide: Vec = OsStr::new(path) - .encode_wide() - .chain(std::iter::once(0)) - .collect(); - - let result = unsafe { - SetNamedSecurityInfoW( - path_wide.as_ptr() as *mut _, - SE_FILE_OBJECT, - DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, - std::ptr::null_mut(), - std::ptr::null_mut(), - acl, - std::ptr::null_mut(), - ) - }; - - if result != ERROR_SUCCESS { - return Err(AppError::io(path, std::io::Error::from_raw_os_error(result as i32))); - } - - Ok(()) + crate::cli::windows_temp_launch::create_secret_temp_file(path) } fn cleanup_temp_settings_file(path: &Path) -> Result<(), AppError> { @@ -1178,151 +744,6 @@ mod tests { assert_eq!(written["permissions"]["allow"], json!(["Bash(git*)"])); } - #[cfg(windows)] - #[test] - fn quote_windows_arg_for_cmd_quotes_special_chars() { - assert_eq!(quote_windows_arg_for_cmd("foo&bar"), "\"foo&bar\""); - assert_eq!(quote_windows_arg_for_cmd("foo|bar"), "\"foo|bar\""); - assert_eq!(quote_windows_arg_for_cmd("foobar"), "\"foo>bar\""); - assert_eq!(quote_windows_arg_for_cmd("foo^bar"), "\"foo^bar\""); - assert_eq!(quote_windows_arg_for_cmd("foo%bar"), "\"foo%bar\""); - assert_eq!(quote_windows_arg_for_cmd("foo!bar"), "\"foo!bar\""); - assert_eq!(quote_windows_arg_for_cmd("foo(bar"), "\"foo(bar\""); - assert_eq!(quote_windows_arg_for_cmd("foo)bar"), "\"foo)bar\""); - } - - #[cfg(windows)] - #[test] - fn quote_windows_arg_for_cmd_escapes_quotes() { - assert_eq!( - quote_windows_arg_for_cmd("foo\"bar"), - "\"foo\\\"bar\"" - ); - } - - #[cfg(windows)] - #[test] - fn quote_windows_arg_for_cmd_quotes_spaces_and_specials() { - assert_eq!( - quote_windows_arg_for_cmd("foo & bar"), - "\"foo & bar\"" - ); - } - - #[cfg(windows)] - #[test] - fn quote_windows_arg_for_cmd_leaves_plain_args_unchanged() { - assert_eq!(quote_windows_arg_for_cmd("normal"), "normal"); - assert_eq!(quote_windows_arg_for_cmd("C:\\path\\file.exe"), "C:\\path\\file.exe"); - } - - #[cfg(windows)] - #[test] - fn quote_windows_arg_for_cmd_handles_empty_string() { - assert_eq!(quote_windows_arg_for_cmd(""), "\"\""); - } - - #[cfg(windows)] - #[test] - fn is_cmd_shim_matches_cmd_and_bat_case_insensitive() { - assert!(is_cmd_shim(std::path::Path::new("C:\\bin\\claude.cmd"))); - assert!(is_cmd_shim(std::path::Path::new("C:\\bin\\claude.CMD"))); - assert!(is_cmd_shim(std::path::Path::new("C:\\bin\\claude.bat"))); - assert!(is_cmd_shim(std::path::Path::new("C:\\bin\\claude.BAT"))); - assert!(!is_cmd_shim(std::path::Path::new("C:\\bin\\claude.exe"))); - assert!(!is_cmd_shim(std::path::Path::new("C:\\bin\\claude"))); - } - - #[cfg(windows)] - #[test] - fn arg_requires_cmd_quote_recognizes_unsafe_inputs() { - assert!(arg_requires_cmd_quote("")); - assert!(arg_requires_cmd_quote("foo bar")); - assert!(arg_requires_cmd_quote("foo\tbar")); - assert!(arg_requires_cmd_quote("a&b")); - assert!(arg_requires_cmd_quote("a|b")); - assert!(arg_requires_cmd_quote("a%b")); - assert!(arg_requires_cmd_quote("a!b")); - assert!(arg_requires_cmd_quote("a(b")); - assert!(arg_requires_cmd_quote("a)b")); - assert!(!arg_requires_cmd_quote("plain")); - assert!(!arg_requires_cmd_quote("C:\\work\\")); - assert!(!arg_requires_cmd_quote("--project-dir=C:\\tmp\\")); - } - - #[cfg(windows)] - #[test] - fn build_windows_cmdline_accepts_plain_trailing_backslash_paths() { - let prepared = PreparedClaudeLaunch { - executable: PathBuf::from("C:\\bin\\claude.cmd"), - settings_path: PathBuf::from("C:\\tmp\\settings.json"), - }; - let native_args = vec![ - OsString::from("C:\\work\\"), - OsString::from("--project-dir=C:\\tmp\\"), - ]; - - let cmdline = build_windows_cmdline(&prepared, &native_args) - .expect("plain trailing backslash should be allowed"); - - let cmdline_str = String::from_utf16_lossy(&cmdline); - assert!(cmdline_str.contains("cmd.exe /c")); - assert!(cmdline_str.contains("C:\\work\\")); - assert!(cmdline_str.contains("--project-dir=C:\\tmp\\")); - } - - #[cfg(windows)] - #[test] - fn build_windows_cmdline_rejects_trailing_backslash_when_quoting_required() { - let prepared = PreparedClaudeLaunch { - executable: PathBuf::from("C:\\bin\\claude.cmd"), - settings_path: PathBuf::from("C:\\tmp\\settings.json"), - }; - let native_args = vec![OsString::from("C:\\Program Files\\dir\\")]; - - let err = build_windows_cmdline(&prepared, &native_args) - .expect_err("space + trailing backslash must be rejected"); - let msg = err.to_string(); - assert!(msg.contains("backslash") || msg.contains("反斜杠")); - } - - #[cfg(windows)] - #[test] - fn build_windows_cmdline_rejects_trailing_backslash_with_special_char() { - let prepared = PreparedClaudeLaunch { - executable: PathBuf::from("C:\\bin\\claude.cmd"), - settings_path: PathBuf::from("C:\\tmp\\settings.json"), - }; - let native_args = vec![OsString::from("a&b\\")]; - - let err = build_windows_cmdline(&prepared, &native_args) - .expect_err("special char + trailing backslash must be rejected"); - let msg = err.to_string(); - assert!(msg.contains("backslash") || msg.contains("反斜杠")); - } - - #[cfg(windows)] - #[test] - fn build_windows_cmdline_passes_through_for_direct_binary() { - let prepared = PreparedClaudeLaunch { - executable: PathBuf::from("C:\\bin\\claude.exe"), - settings_path: PathBuf::from("C:\\tmp\\settings.json"), - }; - // Direct .exe path skips cmd quoting; trailing backslash is fine even - // alongside chars that would normally require quoting. - let native_args = vec![ - OsString::from("C:\\work\\"), - OsString::from("--project-dir=C:\\Program Files\\dir\\"), - ]; - - let cmdline = build_windows_cmdline(&prepared, &native_args) - .expect("direct .exe must accept trailing backslash regardless of quoting"); - let cmdline_str = String::from_utf16_lossy(&cmdline); - assert!(cmdline_str.contains("C:\\work\\")); - assert!(cmdline_str.contains("Program Files")); - } - #[test] fn prepare_launch_from_settings_writes_exact_effective_snapshot() { let temp_dir = TempDir::new().expect("create temp dir"); diff --git a/src-tauri/src/cli/codex_temp_launch.rs b/src-tauri/src/cli/codex_temp_launch.rs index d9563690..5e1ef02e 100644 --- a/src-tauri/src/cli/codex_temp_launch.rs +++ b/src-tauri/src/cli/codex_temp_launch.rs @@ -1,5 +1,5 @@ use std::ffi::OsString; -use std::fs::{self, File, OpenOptions}; +use std::fs::{self, File}; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -12,24 +12,7 @@ use crate::provider::Provider; use serde_json::Value; #[cfg(windows)] -use std::os::windows::ffi::OsStrExt; -#[cfg(windows)] -use std::ptr; -#[cfg(windows)] -use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, FALSE, HANDLE, TRUE}; -#[cfg(windows)] -use windows_sys::Win32::System::Console::SetConsoleCtrlHandler; -#[cfg(windows)] -use windows_sys::Win32::System::JobObjects::{ - AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, - SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, - JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, -}; -#[cfg(windows)] -use windows_sys::Win32::System::Threading::{ - CreateProcessW, GetExitCodeProcess, ResumeThread, TerminateProcess, WaitForSingleObject, - CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, INFINITE, PROCESS_INFORMATION, STARTUPINFOW, -}; +use windows_sys::Win32::System::Threading::TerminateProcess; #[derive(Debug, Clone)] pub(crate) struct PreparedCodexLaunch { @@ -132,6 +115,13 @@ pub(crate) fn exec_prepared_codex( prepared: &PreparedCodexLaunch, native_args: &[OsString], ) -> Result<(), AppError> { + use crate::cli::windows_temp_launch::{ + build_env_block_with_override, is_cmd_shim, Job, ScopedConsoleCtrlHandler, + spawn_suspended_createprocessw, wait_for_child, + }; + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::ResumeThread; + let _ctrl_guard = ScopedConsoleCtrlHandler::install()?; let (program, args) = build_command_windows(prepared, native_args)?; @@ -170,7 +160,7 @@ pub(crate) fn exec_prepared_codex( let resume_result = unsafe { ResumeThread(thread_handle) }; if resume_result == u32::MAX { - let code = unsafe { GetLastError() }; + let code = unsafe { windows_sys::Win32::Foundation::GetLastError() }; unsafe { let _ = TerminateProcess(process_handle, 1); CloseHandle(thread_handle); @@ -213,35 +203,13 @@ pub(crate) fn exec_prepared_codex( )) } -#[cfg(windows)] -fn is_cmd_shim(path: &std::path::Path) -> bool { - path.extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat")) - .unwrap_or(false) -} - -/// Returns true when `quote_windows_arg_for_cmd` would wrap `s` in double -/// quotes. We mirror its predicate (sans the `"` case, which is rejected by -/// the caller before this is consulted) so callers can decide whether a -/// trailing `\` is dangerous: only quoted args risk the trailing `\` -/// escaping the closing `"`. Plain Windows paths like `C:\work\` pass -/// through unquoted and are safe. -#[cfg(windows)] -fn arg_requires_cmd_quote(s: &str) -> bool { - const CMD_SPECIAL: &[char] = &['&', '|', '<', '>', '^', '%', '!', '(', ')']; - s.is_empty() - || s.contains(' ') - || s.contains('\t') - || s.contains('\n') - || s.chars().any(|c| CMD_SPECIAL.contains(&c)) -} - #[cfg(windows)] fn build_command_windows( prepared: &PreparedCodexLaunch, native_args: &[OsString], ) -> Result<(std::path::PathBuf, Vec), AppError> { + use crate::cli::windows_temp_launch::{arg_requires_cmd_quote, is_cmd_shim}; + if is_cmd_shim(&prepared.executable) { // cmd.exe expands %VAR% and !VAR! (delayed expansion) even inside // double quotes. There is no standard escape for these in a /c @@ -300,325 +268,6 @@ fn build_command_windows( } } -#[cfg(windows)] -fn build_windows_command_line(program: &std::ffi::OsStr, args: &[OsString]) -> Vec { - let program_str = program.to_string_lossy(); - let is_cmd = program_str.eq_ignore_ascii_case("cmd.exe") - || program_str.eq_ignore_ascii_case("cmd"); - - let mut line = String::new(); - line.push_str("e_windows_arg(&program_str)); - - let mut after_c = false; - for arg in args { - line.push(' '); - let arg_str = arg.to_string_lossy(); - if is_cmd && after_c { - line.push_str("e_windows_arg_for_cmd(&arg_str)); - } else { - line.push_str("e_windows_arg(&arg_str)); - if is_cmd && arg_str.eq_ignore_ascii_case("/c") { - after_c = true; - } - } - } - std::ffi::OsStr::new(&line) - .encode_wide() - .chain(Some(0)) - .collect() -} - -#[cfg(windows)] -fn quote_windows_arg(arg: &str) -> String { - if arg.is_empty() { - return "\"\"".to_string(); - } - if !arg.contains(' ') && !arg.contains('\t') && !arg.contains('\n') && !arg.contains('"') { - return arg.to_string(); - } - let mut result = String::with_capacity(arg.len() + 2); - result.push('"'); - let mut chars = arg.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '"' { - result.push('\\'); - result.push('"'); - } else if ch == '\\' { - let mut count = 1; - while chars.peek() == Some(&'\\') { - count += 1; - chars.next(); - } - if chars.peek() == Some(&'"') || chars.peek().is_none() { - for _ in 0..count * 2 { - result.push('\\'); - } - } else { - for _ in 0..count { - result.push('\\'); - } - } - } else { - result.push(ch); - } - } - result.push('"'); - result -} - -#[cfg(windows)] -fn quote_windows_arg_for_cmd(arg: &str) -> String { - const CMD_SPECIAL: &[char] = &['&', '|', '<', '>', '^', '%', '!', '(', ')']; - let needs_quote = arg.is_empty() - || arg.contains(' ') - || arg.contains('\t') - || arg.contains('\n') - || arg.contains('"') - || arg.chars().any(|c| CMD_SPECIAL.contains(&c)); - - if !needs_quote { - return arg.to_string(); - } - - let mut result = String::with_capacity(arg.len() + 2); - result.push('"'); - let mut chars = arg.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '"' { - result.push('\\'); - result.push('"'); - } else if ch == '\\' { - let mut count = 1; - while chars.peek() == Some(&'\\') { - count += 1; - chars.next(); - } - if chars.peek() == Some(&'"') || chars.peek().is_none() { - for _ in 0..count * 2 { - result.push('\\'); - } - } else { - for _ in 0..count { - result.push('\\'); - } - } - } else { - result.push(ch); - } - } - result.push('"'); - result -} - -#[cfg(windows)] -fn spawn_suspended_createprocessw( - program: &std::path::Path, - args: &[OsString], - env_block: Option<&[u16]>, - application_name: Option<&std::path::Path>, -) -> Result<(HANDLE, HANDLE), AppError> { - // When `application_name` is `Some`, it is passed verbatim as - // `lpApplicationName` to `CreateProcessW`. In that mode CreateProcessW does - // NOT search PATH — the caller must supply a fully-resolved path. When - // `application_name` is `None` we pass NULL, which lets Windows parse the - // program name from the start of the command line and search PATH/PATHEXT - // (required for unqualified names like `cmd.exe`). - let application_name_wide: Option> = application_name.map(|p| { - std::ffi::OsStr::new(p) - .encode_wide() - .chain(Some(0)) - .collect() - }); - - let mut command_line = build_windows_command_line(std::ffi::OsStr::new(program), args); - - let mut startup_info: STARTUPINFOW = unsafe { std::mem::zeroed() }; - startup_info.cb = std::mem::size_of::() as u32; - - let mut process_info: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; - - let env_ptr = env_block - .map(|b| b.as_ptr() as *mut _) - .unwrap_or(ptr::null_mut()); - - let app_name_ptr = application_name_wide - .as_ref() - .map(|s| s.as_ptr()) - .unwrap_or(ptr::null()); - - let result = unsafe { - CreateProcessW( - app_name_ptr, - command_line.as_mut_ptr(), - ptr::null_mut(), - ptr::null_mut(), - FALSE, - CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT, - env_ptr, - ptr::null(), - &startup_info, - &mut process_info, - ) - }; - - if result == 0 { - let code = unsafe { GetLastError() }; - return Err(AppError::windows_create_process_failed(code)); - } - - Ok((process_info.hProcess, process_info.hThread)) -} - -#[cfg(windows)] -struct Job { - handle: HANDLE, -} - -#[cfg(windows)] -impl Job { - fn create_with_kill_on_close() -> Result { - unsafe { - let handle = CreateJobObjectW(ptr::null_mut(), ptr::null()); - if handle.is_null() { - let code = GetLastError(); - return Err(AppError::localized( - "windows.create_job_object_failed", - format!("创建 Job Object 失败,Win32 错误码: {code}"), - format!("Failed to create Job Object, Win32 error: {code}"), - )); - } - - let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); - info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - - let result = SetInformationJobObject( - handle, - JobObjectExtendedLimitInformation, - &mut info as *mut _ as *mut _, - std::mem::size_of::() as u32, - ); - - if result == 0 { - let code = GetLastError(); - CloseHandle(handle); - return Err(AppError::localized( - "windows.set_job_information_failed", - format!("设置 Job Object 信息失败,Win32 错误码: {code}"), - format!("Failed to set Job Object information, Win32 error: {code}"), - )); - } - - Ok(Job { handle }) - } - } - - fn try_assign(&self, process: HANDLE) -> Result<(), std::io::Error> { - unsafe { - let result = AssignProcessToJobObject(self.handle, process); - if result == 0 { - Err(std::io::Error::last_os_error()) - } else { - Ok(()) - } - } - } -} - -#[cfg(windows)] -impl Drop for Job { - fn drop(&mut self) { - unsafe { - CloseHandle(self.handle); - } - } -} - -#[cfg(windows)] -struct ScopedConsoleCtrlHandler; - -#[cfg(windows)] -impl ScopedConsoleCtrlHandler { - fn install() -> Result { - unsafe { - let result = SetConsoleCtrlHandler(Some(ctrl_handler_swallow), TRUE); - if result == 0 { - return Err(AppError::localized( - "windows.set_console_ctrl_handler_failed", - "设置控制台 Ctrl 处理器失败".to_string(), - "Failed to set console Ctrl handler.".to_string(), - )); - } - } - Ok(ScopedConsoleCtrlHandler) - } -} - -#[cfg(windows)] -impl Drop for ScopedConsoleCtrlHandler { - fn drop(&mut self) { - unsafe { - SetConsoleCtrlHandler(Some(ctrl_handler_swallow), FALSE); - } - } -} - -#[cfg(windows)] -unsafe extern "system" fn ctrl_handler_swallow(_ctrl_type: u32) -> i32 { - TRUE -} - -#[cfg(windows)] -fn wait_for_child(process_handle: HANDLE) -> Result { - unsafe { - let wait_result = WaitForSingleObject(process_handle, INFINITE); - if wait_result != 0 { - let code = GetLastError(); - return Err(AppError::localized( - "windows.wait_for_child_failed", - format!("等待子进程失败,Win32 错误码: {code}"), - format!("Failed to wait for child process, Win32 error: {code}"), - )); - } - - let mut exit_code: u32 = 0; - if GetExitCodeProcess(process_handle, &mut exit_code) == 0 { - let code = GetLastError(); - return Err(AppError::localized( - "windows.get_exit_code_failed", - format!("获取子进程退出码失败,Win32 错误码: {code}"), - format!("Failed to get child exit code, Win32 error: {code}"), - )); - } - - Ok(exit_code) - } -} - -#[cfg(windows)] -fn build_env_block_with_override(key: &str, value: &std::ffi::OsStr) -> Vec { - use std::os::windows::ffi::OsStrExt; - - let mut result = Vec::new(); - for (k, v) in std::env::vars_os() { - // Windows environment variable names are case-insensitive. - if k.to_string_lossy().eq_ignore_ascii_case(key) { - continue; - } - result.extend(k.encode_wide()); - result.push(b'=' as u16); - result.extend(v.encode_wide()); - result.push(0); - } - // Add our override - result.extend(key.encode_utf16()); - result.push(b'=' as u16); - result.extend(value.encode_wide()); - result.push(0); - // Double-null terminate the block - result.push(0); - result -} - fn write_temp_codex_home(temp_dir: &Path, provider: &Provider) -> Result { write_temp_codex_home_with(temp_dir, provider, finalize_temp_codex_home) } @@ -727,7 +376,7 @@ fn finalize_temp_codex_home(path: &Path) -> Result<(), AppError> { fn finalize_temp_codex_home(path: &Path) -> Result<(), AppError> { #[cfg(windows)] { - restrict_to_owner(path, true)?; + crate::cli::windows_temp_launch::restrict_to_owner(path, true)?; } Ok(()) } @@ -753,121 +402,7 @@ fn create_secret_temp_file(path: &Path) -> Result { #[cfg(not(unix))] fn create_secret_temp_file(path: &Path) -> Result { - let file = OpenOptions::new() - .write(true) - .create_new(true) - .open(path) - .map_err(|err| AppError::io(path, err))?; - #[cfg(windows)] - { - restrict_to_owner(path, false)?; - } - Ok(file) -} - -#[cfg(windows)] -fn restrict_to_owner(path: &Path, inherit: bool) -> Result<(), AppError> { - use std::ffi::OsStr; - use std::os::windows::ffi::OsStrExt; - use windows_sys::Win32::Foundation::{CloseHandle, ERROR_SUCCESS, HANDLE}; - use windows_sys::Win32::Security::Authorization::{ - SetNamedSecurityInfoW, SE_FILE_OBJECT, - }; - use windows_sys::Win32::Security::{ - ACL, AddAccessAllowedAceEx, DACL_SECURITY_INFORMATION, - GetLengthSid, GetTokenInformation, InitializeAcl, - PROTECTED_DACL_SECURITY_INFORMATION, - TOKEN_QUERY, TOKEN_USER, TokenUser, - }; - use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; - - const NO_INHERITANCE: u32 = 0; - const OBJECT_INHERIT_ACE: u32 = 0x1; - const CONTAINER_INHERIT_ACE: u32 = 0x2; - const FILE_ALL_ACCESS: u32 = 0x1F01FF; - const ACL_REVISION: u32 = 2; - - // Open current process token to get the user SID - let mut token: HANDLE = std::ptr::null_mut(); - let result = unsafe { - OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) - }; - if result == 0 { - return Err(AppError::io(path, std::io::Error::last_os_error())); - } - - // Get token user info (first call to get size) - let mut size = 0u32; - unsafe { - GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut size); - } - - let mut buffer = vec![0u8; size as usize]; - let result = unsafe { - GetTokenInformation( - token, - TokenUser, - buffer.as_mut_ptr() as *mut _, - size, - &mut size, - ) - }; - if result == 0 { - unsafe { CloseHandle(token) }; - return Err(AppError::io(path, std::io::Error::last_os_error())); - } - - let token_user = unsafe { &*(buffer.as_ptr() as *const TOKEN_USER) }; - let user_sid = token_user.User.Sid; - - unsafe { CloseHandle(token) }; - - let sid_len = unsafe { GetLengthSid(user_sid) }; - - let acl_size = (std::mem::size_of::() + 8 + sid_len as usize) as u32; - let mut acl_buffer = vec![0u8; acl_size as usize]; - let acl = acl_buffer.as_mut_ptr() as *mut ACL; - - let result = unsafe { InitializeAcl(acl, acl_size, ACL_REVISION) }; - if result == 0 { - return Err(AppError::io(path, std::io::Error::last_os_error())); - } - - let ace_flags = if inherit { - OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE - } else { - NO_INHERITANCE - }; - - let result = unsafe { - AddAccessAllowedAceEx(acl, ACL_REVISION, ace_flags, FILE_ALL_ACCESS, user_sid) - }; - if result == 0 { - return Err(AppError::io(path, std::io::Error::last_os_error())); - } - - let path_wide: Vec = OsStr::new(path) - .encode_wide() - .chain(std::iter::once(0)) - .collect(); - - let result = unsafe { - SetNamedSecurityInfoW( - path_wide.as_ptr() as *mut _, - SE_FILE_OBJECT, - DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, - std::ptr::null_mut(), - std::ptr::null_mut(), - acl, - std::ptr::null_mut(), - ) - }; - - if result != ERROR_SUCCESS { - return Err(AppError::io(path, std::io::Error::from_raw_os_error(result as i32))); - } - - Ok(()) + crate::cli::windows_temp_launch::create_secret_temp_file(path) } fn cleanup_temp_codex_home(path: &Path) -> Result<(), AppError> { @@ -906,81 +441,6 @@ mod tests { use std::time::Duration; use tempfile::TempDir; - #[cfg(windows)] - #[test] - fn quote_windows_arg_for_cmd_quotes_special_chars() { - assert_eq!(quote_windows_arg_for_cmd("foo&bar"), "\"foo&bar\""); - assert_eq!(quote_windows_arg_for_cmd("foo|bar"), "\"foo|bar\""); - assert_eq!(quote_windows_arg_for_cmd("foobar"), "\"foo>bar\""); - assert_eq!(quote_windows_arg_for_cmd("foo^bar"), "\"foo^bar\""); - assert_eq!(quote_windows_arg_for_cmd("foo%bar"), "\"foo%bar\""); - assert_eq!(quote_windows_arg_for_cmd("foo!bar"), "\"foo!bar\""); - assert_eq!(quote_windows_arg_for_cmd("foo(bar"), "\"foo(bar\""); - assert_eq!(quote_windows_arg_for_cmd("foo)bar"), "\"foo)bar\""); - } - - #[cfg(windows)] - #[test] - fn quote_windows_arg_for_cmd_escapes_quotes() { - assert_eq!( - quote_windows_arg_for_cmd("foo\"bar"), - "\"foo\\\"bar\"" - ); - } - - #[cfg(windows)] - #[test] - fn quote_windows_arg_for_cmd_quotes_spaces_and_specials() { - assert_eq!( - quote_windows_arg_for_cmd("foo & bar"), - "\"foo & bar\"" - ); - } - - #[cfg(windows)] - #[test] - fn quote_windows_arg_for_cmd_leaves_plain_args_unchanged() { - assert_eq!(quote_windows_arg_for_cmd("normal"), "normal"); - assert_eq!(quote_windows_arg_for_cmd("C:\\path\\file.exe"), "C:\\path\\file.exe"); - } - - #[cfg(windows)] - #[test] - fn quote_windows_arg_for_cmd_handles_empty_string() { - assert_eq!(quote_windows_arg_for_cmd(""), "\"\""); - } - - #[cfg(windows)] - #[test] - fn quote_windows_arg_handles_newlines() { - assert_eq!(quote_windows_arg("foo\nbar"), "\"foo\nbar\""); - } - - #[cfg(windows)] - #[test] - fn is_cmd_shim_matches_cmd_and_bat_case_insensitive() { - assert!(is_cmd_shim(std::path::Path::new("C:/tools/codex.cmd"))); - assert!(is_cmd_shim(std::path::Path::new("C:/tools/codex.CMD"))); - assert!(is_cmd_shim(std::path::Path::new("C:/tools/codex.bat"))); - assert!(is_cmd_shim(std::path::Path::new("C:/tools/codex.BaT"))); - assert!(!is_cmd_shim(std::path::Path::new("C:/tools/codex.exe"))); - assert!(!is_cmd_shim(std::path::Path::new("C:/tools/codex"))); - } - - #[cfg(windows)] - #[test] - fn arg_requires_cmd_quote_recognizes_unsafe_inputs() { - assert!(arg_requires_cmd_quote("")); - assert!(arg_requires_cmd_quote("a b")); - assert!(arg_requires_cmd_quote("a\tb")); - assert!(arg_requires_cmd_quote("a&b")); - assert!(arg_requires_cmd_quote("a%b")); - assert!(!arg_requires_cmd_quote("plain")); - assert!(!arg_requires_cmd_quote("C:\\work\\")); - assert!(!arg_requires_cmd_quote("--project-dir=C:\\tmp\\")); - } - #[cfg(windows)] #[test] fn build_command_windows_accepts_plain_trailing_backslash_paths() { diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index bcb0dfa5..6889f44c 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -4,6 +4,8 @@ use std::io::Write; mod claude_temp_launch; mod codex_temp_launch; +#[cfg(windows)] +mod windows_temp_launch; pub mod commands; pub mod editor; pub mod i18n; diff --git a/src-tauri/src/cli/windows_temp_launch.rs b/src-tauri/src/cli/windows_temp_launch.rs new file mode 100644 index 00000000..1b4ceb73 --- /dev/null +++ b/src-tauri/src/cli/windows_temp_launch.rs @@ -0,0 +1,619 @@ +use std::ffi::{OsStr, OsString}; +use std::fs::{File, OpenOptions}; +use std::path::Path; + +use crate::error::AppError; + +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt; +#[cfg(windows)] +use windows_sys::Win32::Foundation::{ + CloseHandle, GetLastError, FALSE, HANDLE, INVALID_HANDLE_VALUE, TRUE, +}; +#[cfg(windows)] +use windows_sys::Win32::System::Console::SetConsoleCtrlHandler; +#[cfg(windows)] +use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, + SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, +}; +#[cfg(windows)] +use windows_sys::Win32::System::Threading::{ + CreateProcessW, GetExitCodeProcess, ResumeThread, WaitForSingleObject, + CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, INFINITE, PROCESS_INFORMATION, STARTUPINFOW, +}; + +// ── cmd shim detection ─────────────────────────────────────────────── + +#[cfg(windows)] +pub(crate) fn is_cmd_shim(path: &std::path::Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat")) + .unwrap_or(false) +} + +// ── argument quoting ───────────────────────────────────────────────── + +/// Returns true when `quote_windows_arg_for_cmd` would wrap `s` in double +/// quotes. We mirror its predicate (sans the `"` case, which is rejected by +/// the caller before this is consulted) so callers can decide whether a +/// trailing `\` is dangerous: only quoted args risk the trailing `\` +/// escaping the closing `"`. Plain Windows paths like `C:\work\` pass +/// through unquoted and are safe. +#[cfg(windows)] +pub(crate) fn arg_requires_cmd_quote(s: &str) -> bool { + const CMD_SPECIAL: &[char] = &['&', '|', '<', '>', '^', '%', '!', '(', ')']; + s.is_empty() + || s.contains(' ') + || s.contains('\t') + || s.contains('\n') + || s.chars().any(|c| CMD_SPECIAL.contains(&c)) +} + +#[cfg(windows)] +pub(crate) fn quote_windows_arg(arg: &str) -> String { + if arg.is_empty() { + return "\"\"".to_string(); + } + if !arg.contains(' ') && !arg.contains('\t') && !arg.contains('\n') && !arg.contains('"') { + return arg.to_string(); + } + + let mut result = String::with_capacity(arg.len() + 2); + result.push('"'); + + let mut chars = arg.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '"' { + result.push('\\'); + result.push('"'); + } else if ch == '\\' { + let mut count = 1; + while chars.peek() == Some(&'\\') { + count += 1; + chars.next(); + } + if chars.peek() == Some(&'"') || chars.peek().is_none() { + for _ in 0..count * 2 { + result.push('\\'); + } + } else { + for _ in 0..count { + result.push('\\'); + } + } + } else { + result.push(ch); + } + } + + result.push('"'); + result +} + +#[cfg(windows)] +pub(crate) fn quote_windows_arg_for_cmd(arg: &str) -> String { + const CMD_SPECIAL: &[char] = &['&', '|', '<', '>', '^', '%', '!', '(', ')']; + let needs_quote = arg.is_empty() + || arg.contains(' ') + || arg.contains('\t') + || arg.contains('\n') + || arg.contains('"') + || arg.chars().any(|c| CMD_SPECIAL.contains(&c)); + + if !needs_quote { + return arg.to_string(); + } + + let mut result = String::with_capacity(arg.len() + 2); + result.push('"'); + + let mut chars = arg.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '"' { + result.push('\\'); + result.push('"'); + } else if ch == '\\' { + let mut count = 1; + while chars.peek() == Some(&'\\') { + count += 1; + chars.next(); + } + if chars.peek() == Some(&'"') || chars.peek().is_none() { + for _ in 0..count * 2 { + result.push('\\'); + } + } else { + for _ in 0..count { + result.push('\\'); + } + } + } else { + result.push(ch); + } + } + + result.push('"'); + result +} + +// ── command line construction ──────────────────────────────────────── + +#[cfg(windows)] +pub(crate) fn build_windows_command_line(program: &OsStr, args: &[OsString]) -> Vec { + let program_str = program.to_string_lossy(); + let is_cmd = program_str.eq_ignore_ascii_case("cmd.exe") || program_str.eq_ignore_ascii_case("cmd"); + + let mut line = String::new(); + line.push_str("e_windows_arg(&program_str)); + + let mut after_c = false; + for arg in args { + line.push(' '); + let arg_str = arg.to_string_lossy(); + if is_cmd && after_c { + line.push_str("e_windows_arg_for_cmd(&arg_str)); + } else { + line.push_str("e_windows_arg(&arg_str)); + if is_cmd && arg_str.eq_ignore_ascii_case("/c") { + after_c = true; + } + } + } + std::ffi::OsStr::new(&line) + .encode_wide() + .chain(Some(0)) + .collect() +} + +#[cfg(windows)] +pub(crate) fn build_env_block_with_override(key: &str, value: &OsStr) -> Vec { + use std::os::windows::ffi::OsStrExt; + + let mut result = Vec::new(); + for (k, v) in std::env::vars_os() { + // Windows environment variable names are case-insensitive. + if k.to_string_lossy().eq_ignore_ascii_case(key) { + continue; + } + result.extend(k.encode_wide()); + result.push(b'=' as u16); + result.extend(v.encode_wide()); + result.push(0); + } + // Add our override + result.extend(key.encode_utf16()); + result.push(b'=' as u16); + result.extend(value.encode_wide()); + result.push(0); + // Double-null terminate the block + result.push(0); + result +} + +// ── Ctrl handler guard ─────────────────────────────────────────────── + +#[cfg(windows)] +pub(crate) struct ScopedConsoleCtrlHandler; + +#[cfg(windows)] +impl ScopedConsoleCtrlHandler { + pub(crate) fn install() -> Result { + unsafe { + let result = SetConsoleCtrlHandler(Some(ctrl_handler_swallow), TRUE); + if result == 0 { + return Err(AppError::localized( + "windows.set_console_ctrl_handler_failed", + "设置控制台 Ctrl 处理器失败".to_string(), + "Failed to set console Ctrl handler.".to_string(), + )); + } + } + Ok(ScopedConsoleCtrlHandler) + } +} + +#[cfg(windows)] +impl Drop for ScopedConsoleCtrlHandler { + fn drop(&mut self) { + unsafe { + let _ = SetConsoleCtrlHandler(Some(ctrl_handler_swallow), FALSE); + } + } +} + +#[cfg(windows)] +unsafe extern "system" fn ctrl_handler_swallow(_ctrl_type: u32) -> i32 { + TRUE +} + +// ── Job Object ─────────────────────────────────────────────────────── + +#[cfg(windows)] +pub(crate) struct Job { + handle: HANDLE, +} + +#[cfg(windows)] +impl Job { + pub(crate) fn create_with_kill_on_close() -> Result { + unsafe { + let handle = CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()); + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + let code = GetLastError(); + return Err(AppError::windows_create_job_object_failed(code)); + } + + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + + let result = SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + &mut info as *mut _ as *mut _, + std::mem::size_of::() as u32, + ); + + if result == 0 { + let code = GetLastError(); + CloseHandle(handle); + return Err(AppError::windows_set_job_information_failed(code)); + } + + Ok(Job { handle }) + } + } + + pub(crate) fn try_assign(&self, process: HANDLE) -> Result<(), std::io::Error> { + unsafe { + let result = AssignProcessToJobObject(self.handle, process); + if result == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + } + } +} + +#[cfg(windows)] +impl Drop for Job { + fn drop(&mut self) { + unsafe { + let _ = CloseHandle(self.handle); + } + } +} + +// ── process spawning ───────────────────────────────────────────────── + +#[cfg(windows)] +pub(crate) fn spawn_suspended_createprocessw( + program: &std::path::Path, + args: &[OsString], + env_block: Option<&[u16]>, + application_name: Option<&std::path::Path>, +) -> Result<(HANDLE, HANDLE), AppError> { + use std::ptr; + + let application_name_wide: Option> = application_name.map(|p| { + std::ffi::OsStr::new(p) + .encode_wide() + .chain(Some(0)) + .collect() + }); + + let mut command_line = build_windows_command_line(std::ffi::OsStr::new(program), args); + + let mut startup_info: STARTUPINFOW = unsafe { std::mem::zeroed() }; + startup_info.cb = std::mem::size_of::() as u32; + + let mut process_info: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + + let env_ptr = env_block + .map(|b| b.as_ptr() as *mut _) + .unwrap_or(ptr::null_mut()); + + let app_name_ptr = application_name_wide + .as_ref() + .map(|s| s.as_ptr()) + .unwrap_or(ptr::null()); + + let result = unsafe { + CreateProcessW( + app_name_ptr, + command_line.as_mut_ptr(), + ptr::null_mut(), + ptr::null_mut(), + FALSE, + CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT, + env_ptr, + ptr::null(), + &startup_info, + &mut process_info, + ) + }; + + if result == 0 { + let code = unsafe { GetLastError() }; + return Err(AppError::windows_create_process_failed(code)); + } + + Ok((process_info.hProcess, process_info.hThread)) +} + +#[cfg(windows)] +pub(crate) fn wait_for_child(process_handle: HANDLE) -> Result { + unsafe { + let wait_result = WaitForSingleObject(process_handle, INFINITE); + if wait_result != 0 { + let code = GetLastError(); + return Err(AppError::localized( + "windows.wait_for_child_failed", + format!("等待子进程失败,Win32 错误码: {code}"), + format!("Failed to wait for child process, Win32 error: {code}"), + )); + } + + let mut exit_code: u32 = 0; + if GetExitCodeProcess(process_handle, &mut exit_code) == 0 { + let code = GetLastError(); + return Err(AppError::localized( + "windows.get_exit_code_failed", + format!("获取子进程退出码失败,Win32 错误码: {code}"), + format!("Failed to get child exit code, Win32 error: {code}"), + )); + } + + Ok(exit_code) + } +} + +// ── ACL / file security ────────────────────────────────────────────── + +#[cfg(windows)] +pub(crate) fn restrict_to_owner(path: &Path, inherit: bool) -> Result<(), AppError> { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::{CloseHandle, ERROR_SUCCESS, HANDLE}; + use windows_sys::Win32::Security::Authorization::{ + SetNamedSecurityInfoW, SE_FILE_OBJECT, + }; + use windows_sys::Win32::Security::{ + ACL, AddAccessAllowedAceEx, DACL_SECURITY_INFORMATION, + GetLengthSid, GetTokenInformation, InitializeAcl, + PROTECTED_DACL_SECURITY_INFORMATION, + TOKEN_QUERY, TOKEN_USER, TokenUser, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + const NO_INHERITANCE: u32 = 0; + const OBJECT_INHERIT_ACE: u32 = 0x1; + const CONTAINER_INHERIT_ACE: u32 = 0x2; + const FILE_ALL_ACCESS: u32 = 0x1F01FF; + const ACL_REVISION: u32 = 2; + + // Open current process token to get the user SID + let mut token: HANDLE = std::ptr::null_mut(); + let result = unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }; + if result == 0 { + return Err(AppError::io(path, std::io::Error::last_os_error())); + } + + // Get token user info (first call to get size) + let mut size = 0u32; + unsafe { + GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut size); + } + + let mut buffer = vec![0u8; size as usize]; + let result = unsafe { + GetTokenInformation( + token, + TokenUser, + buffer.as_mut_ptr() as *mut _, + size, + &mut size, + ) + }; + if result == 0 { + unsafe { CloseHandle(token) }; + return Err(AppError::io(path, std::io::Error::last_os_error())); + } + + let token_user = unsafe { &*(buffer.as_ptr() as *const TOKEN_USER) }; + let user_sid = token_user.User.Sid; + + unsafe { CloseHandle(token) }; + + let sid_len = unsafe { GetLengthSid(user_sid) }; + + // ACL size = ACL header + ACCESS_ALLOWED_ACE without SidStart + SID length + // ACL header = 8 bytes, ACE header+Mask = 8 bytes, SidStart = 4 bytes + let acl_size = (std::mem::size_of::() + 8 + sid_len as usize) as u32; + let mut acl_buffer = vec![0u8; acl_size as usize]; + let acl = acl_buffer.as_mut_ptr() as *mut ACL; + + let result = unsafe { InitializeAcl(acl, acl_size, ACL_REVISION) }; + if result == 0 { + return Err(AppError::io(path, std::io::Error::last_os_error())); + } + + let ace_flags = if inherit { + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + } else { + NO_INHERITANCE + }; + + let result = unsafe { + AddAccessAllowedAceEx(acl, ACL_REVISION, ace_flags, FILE_ALL_ACCESS, user_sid) + }; + if result == 0 { + return Err(AppError::io(path, std::io::Error::last_os_error())); + } + + let path_wide: Vec = OsStr::new(path) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let result = unsafe { + SetNamedSecurityInfoW( + path_wide.as_ptr() as *mut _, + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + acl, + std::ptr::null_mut(), + ) + }; + + if result != ERROR_SUCCESS { + return Err(AppError::io(path, std::io::Error::from_raw_os_error(result as i32))); + } + + Ok(()) +} + +#[cfg(windows)] +pub(crate) fn create_secret_temp_file(path: &Path) -> Result { + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|err| AppError::io(path, err))?; + restrict_to_owner(path, false)?; + Ok(file) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(windows)] + #[test] + fn quote_windows_arg_for_cmd_quotes_special_chars() { + assert_eq!(quote_windows_arg_for_cmd("foo&bar"), "\"foo&bar\""); + assert_eq!(quote_windows_arg_for_cmd("foo|bar"), "\"foo|bar\""); + assert_eq!(quote_windows_arg_for_cmd("foobar"), "\"foo>bar\""); + assert_eq!(quote_windows_arg_for_cmd("foo^bar"), "\"foo^bar\""); + assert_eq!(quote_windows_arg_for_cmd("foo%bar"), "\"foo%bar\""); + assert_eq!(quote_windows_arg_for_cmd("foo!bar"), "\"foo!bar\""); + assert_eq!(quote_windows_arg_for_cmd("foo(bar"), "\"foo(bar\""); + assert_eq!(quote_windows_arg_for_cmd("foo)bar"), "\"foo)bar\""); + } + + #[cfg(windows)] + #[test] + fn quote_windows_arg_for_cmd_escapes_quotes() { + assert_eq!(quote_windows_arg_for_cmd("foo\"bar"), "\"foo\\\"bar\""); + } + + #[cfg(windows)] + #[test] + fn quote_windows_arg_for_cmd_quotes_spaces_and_specials() { + assert_eq!(quote_windows_arg_for_cmd("foo & bar"), "\"foo & bar\""); + } + + #[cfg(windows)] + #[test] + fn quote_windows_arg_for_cmd_leaves_plain_args_unchanged() { + assert_eq!(quote_windows_arg_for_cmd("normal"), "normal"); + assert_eq!( + quote_windows_arg_for_cmd("C:\\path\\file.exe"), + "C:\\path\\file.exe" + ); + } + + #[cfg(windows)] + #[test] + fn quote_windows_arg_for_cmd_handles_empty_string() { + assert_eq!(quote_windows_arg_for_cmd(""), "\"\""); + } + + #[cfg(windows)] + #[test] + fn quote_windows_arg_handles_newlines() { + assert_eq!(quote_windows_arg("foo\nbar"), "\"foo\nbar\""); + } + + #[cfg(windows)] + #[test] + fn is_cmd_shim_matches_cmd_and_bat_case_insensitive() { + assert!(is_cmd_shim(std::path::Path::new("C:/tools/app.cmd"))); + assert!(is_cmd_shim(std::path::Path::new("C:/tools/app.CMD"))); + assert!(is_cmd_shim(std::path::Path::new("C:/tools/app.bat"))); + assert!(is_cmd_shim(std::path::Path::new("C:/tools/app.BaT"))); + assert!(!is_cmd_shim(std::path::Path::new("C:/tools/app.exe"))); + assert!(!is_cmd_shim(std::path::Path::new("C:/tools/app"))); + } + + #[cfg(windows)] + #[test] + fn arg_requires_cmd_quote_recognizes_unsafe_inputs() { + assert!(arg_requires_cmd_quote("")); + assert!(arg_requires_cmd_quote("a b")); + assert!(arg_requires_cmd_quote("a\tb")); + assert!(arg_requires_cmd_quote("a&b")); + assert!(arg_requires_cmd_quote("a%b")); + assert!(!arg_requires_cmd_quote("plain")); + assert!(!arg_requires_cmd_quote("C:\\work\\")); + assert!(!arg_requires_cmd_quote("--project-dir=C:\\tmp\\")); + } + + #[cfg(windows)] + #[test] + fn build_windows_command_line_quotes_after_c_for_cmd() { + let line = build_windows_command_line( + OsStr::new("cmd.exe"), + &[ + OsString::from("/c"), + OsString::from("foo&bar"), + OsString::from("normal"), + ], + ); + let s = String::from_utf16_lossy(&line); + // Should contain quoted foo&bar but not normal + assert!(s.contains("\"foo&bar\"")); + assert!(s.contains("normal")); + } + + /// Smoke test: spawn a real child process via the shared Windows path, + /// assign it to a Job Object, resume it, and verify the exit code. + #[cfg(windows)] + #[test] + fn windows_smoke_test_spawn_job_wait_exit_code() { + let (h_process, h_thread) = spawn_suspended_createprocessw( + std::path::Path::new("cmd.exe"), + &[ + OsString::from("/c"), + OsString::from("exit"), + OsString::from("42"), + ], + None, + None, + ) + .expect("spawn_suspended_createprocessw should succeed for cmd.exe"); + + let job = Job::create_with_kill_on_close() + .expect("Job::create_with_kill_on_close should succeed"); + job.try_assign(h_process) + .expect("try_assign should succeed for a non-nested process"); + + let resume_result = unsafe { ResumeThread(h_thread) }; + assert_ne!( + resume_result, u32::MAX, + "ResumeThread should not fail" + ); + + unsafe { CloseHandle(h_thread) }; + + let exit_code = wait_for_child(h_process).expect("wait_for_child should succeed"); + unsafe { CloseHandle(h_process) }; + + assert_eq!(exit_code, 42, "child exit code should be propagated"); + } +} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index a52bdeb7..ecb8229d 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -113,6 +113,26 @@ impl AppError { format!("CreateProcessW failed with Win32 error code: {code}"), ) } + + /// 错误:创建 Job Object 失败。 + /// i18n key: `windows.create_job_object_failed` + pub fn windows_create_job_object_failed(code: u32) -> Self { + Self::localized( + "windows.create_job_object_failed", + format!("创建 Job Object 失败,Win32 错误码: {code}"), + format!("Failed to create Job Object, Win32 error: {code}"), + ) + } + + /// 错误:设置 Job Object 信息失败。 + /// i18n key: `windows.set_job_information_failed` + pub fn windows_set_job_information_failed(code: u32) -> Self { + Self::localized( + "windows.set_job_information_failed", + format!("设置 Job Object 信息失败,Win32 错误码: {code}"), + format!("Failed to set Job Object information, Win32 error: {code}"), + ) + } } impl From> for AppError { From 6efb75c7a226df3f9c032c32397d4fd5219548f6 Mon Sep 17 00:00:00 2001 From: aloneatwar <2318583515@qq.com> Date: Mon, 27 Apr 2026 03:02:07 +0800 Subject: [PATCH 09/16] fix(windows): address codex review blocking issues - Sort env block alphabetically in build_env_block_with_override per CreateProcessW docs requirement. - Add validate_cmd_arg helper with visible stderr warnings for %/! and hard rejections for quotes and unsafe trailing backslashes. Validate both user native args and internally-constructed args (executable path, settings path, codex_home) in cmd shim mode. - Extract shared run_suspended_child helper to eliminate drift between claude and codex Windows exec paths. - Implement atomic file/dir creation with owner-only ACL via CreateFileW/CreateDirectoryW + SECURITY_DESCRIPTOR, eliminating the TOCTOU window identified by codex review. - Add Win32_Storage_FileSystem feature to windows-sys for CreateFileW/CreateDirectoryW. Co-Authored-By: Claude Opus 4.7 --- src-tauri/.gitignore | 1 + src-tauri/Cargo.toml | 2 +- src-tauri/src/cli/claude_temp_launch.rs | 125 +++----- src-tauri/src/cli/codex_temp_launch.rs | 140 +++------ src-tauri/src/cli/windows_temp_launch.rs | 347 +++++++++++++++++++---- 5 files changed, 383 insertions(+), 232 deletions(-) diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index 502406b4..c504058e 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -2,3 +2,4 @@ # will have compiled files and executables /target/ /gen/schemas +.omc/ diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 39a7dab9..c093c4d1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -90,7 +90,7 @@ tachyonfx = "0.25" [target.'cfg(target_os = "windows")'.dependencies] winreg = "0.52" self-replace = "1" -windows-sys = { version = "0.59", features = ["Win32_System_JobObjects", "Win32_System_Console", "Win32_System_Threading", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization"] } +windows-sys = { version = "0.59", features = ["Win32_System_JobObjects", "Win32_System_Console", "Win32_System_Threading", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem"] } # Optimize release binary size to help reduce AppImage footprint [profile.release] diff --git a/src-tauri/src/cli/claude_temp_launch.rs b/src-tauri/src/cli/claude_temp_launch.rs index 720ae3a1..759d12f1 100644 --- a/src-tauri/src/cli/claude_temp_launch.rs +++ b/src-tauri/src/cli/claude_temp_launch.rs @@ -140,60 +140,21 @@ pub(crate) fn exec_prepared_claude( native_args: &[OsString], ) -> Result<(), AppError> { use crate::cli::windows_temp_launch::{ - Job, ScopedConsoleCtrlHandler, spawn_suspended_createprocessw, wait_for_child, + run_suspended_child, ScopedConsoleCtrlHandler, }; - use windows_sys::Win32::Foundation::CloseHandle; - use windows_sys::Win32::System::Threading::ResumeThread; let _ctrl_guard = ScopedConsoleCtrlHandler::install()?; let (program, args, application_name) = build_claude_command_windows(prepared, native_args)?; - let (h_process, h_thread) = - spawn_suspended_createprocessw(&program, &args, None, application_name.as_deref())?; - - let job = match Job::create_with_kill_on_close() { - Ok(job) => job, - Err(e) => { - unsafe { - let _ = windows_sys::Win32::System::Threading::TerminateProcess(h_process, 1); - CloseHandle(h_thread); - CloseHandle(h_process); - } - return Err(e); - } - }; - - if let Err(e) = job.try_assign(h_process) { - log::warn!(target: "windows.job_assign_failed_fallback", "{}", e); - } - - let resume_result = unsafe { ResumeThread(h_thread) }; - if resume_result == u32::MAX { - unsafe { - let _ = windows_sys::Win32::System::Threading::TerminateProcess(h_process, 1); - CloseHandle(h_thread); - CloseHandle(h_process); - } - return Err(AppError::windows_resume_thread_failed(unsafe { - windows_sys::Win32::Foundation::GetLastError() - })); - } - - unsafe { - CloseHandle(h_thread); - } - - let exit_code = wait_for_child(h_process)?; - unsafe { - CloseHandle(h_process); - } + let exit_code = run_suspended_child(&program, &args, None, application_name.as_deref())?; if exit_code != 0 { - return Err(AppError::Message(format!( - "Claude exited with code {}", - exit_code - ))); + return Err(AppError::localized( + "claude.temp_launch_exit_nonzero", + format!("Claude 进程退出码非零: {exit_code}"), + format!("Claude process exited with non-zero code: {exit_code}"), + )); } Ok(()) @@ -204,46 +165,24 @@ fn build_claude_command_windows( prepared: &PreparedClaudeLaunch, native_args: &[OsString], ) -> Result<(PathBuf, Vec, Option), AppError> { - use crate::cli::windows_temp_launch::{arg_requires_cmd_quote, is_cmd_shim}; + use crate::cli::windows_temp_launch::{is_cmd_shim, validate_cmd_arg}; let exe_str = prepared.executable.to_string_lossy(); let is_cmd = is_cmd_shim(&prepared.executable); if is_cmd { + // Validate internally-constructed arguments that also flow through + // cmd.exe /c (executable path and settings path). + if let Err(e) = validate_cmd_arg(&exe_str) { + return Err(cmd_arg_error_to_app_error("claude", e)); + } + if let Err(e) = validate_cmd_arg(&prepared.settings_path.to_string_lossy()) { + return Err(cmd_arg_error_to_app_error("claude", e)); + } + for arg in native_args { - let s = arg.to_string_lossy(); - if s.contains('%') || s.contains('!') { - log::warn!( - target: "claude_temp_launch", - "Native arg contains % or ! which cmd.exe may expand: {}", - s - ); - } - if s.contains('"') { - return Err(AppError::localized( - "claude.temp_launch_unsafe_cmd_quote", - format!( - "参数包含双引号,无法安全地通过 cmd.exe /c 传递: {}", - s - ), - format!( - "Native arg contains a double quote which cannot be safely passed through cmd.exe /c: {}", - s - ), - )); - } - if s.ends_with('\\') && arg_requires_cmd_quote(&s) { - return Err(AppError::localized( - "claude.temp_launch_unsafe_cmd_trailing_backslash", - format!( - "参数同时需要 cmd.exe 加引号且以反斜杠结尾,无法安全传递: {}", - s - ), - format!( - "Native arg both requires cmd.exe quoting and ends with a backslash, which cannot be safely passed through cmd.exe /c: {}", - s - ), - )); + if let Err(e) = validate_cmd_arg(&arg.to_string_lossy()) { + return Err(cmd_arg_error_to_app_error("claude", e)); } } let mut args = vec![ @@ -264,6 +203,32 @@ fn build_claude_command_windows( } } +#[cfg(windows)] +fn cmd_arg_error_to_app_error(_app_label: &str, err: crate::cli::windows_temp_launch::CmdArgError) -> AppError { + use crate::cli::windows_temp_launch::CmdArgError; + match err { + CmdArgError::DoubleQuote(arg) => AppError::localized( + "claude.temp_launch_unsafe_cmd_quote", + format!("参数包含双引号,无法安全地通过 cmd.exe /c 传递: {}", arg), + format!( + "Native arg contains a double quote which cannot be safely passed through cmd.exe /c: {}", + arg + ), + ), + CmdArgError::UnsafeTrailingBackslash(arg) => AppError::localized( + "claude.temp_launch_unsafe_cmd_trailing_backslash", + format!( + "参数同时需要 cmd.exe 加引号且以反斜杠结尾,无法安全传递: {}", + arg + ), + format!( + "Native arg both requires cmd.exe quoting and ends with a backslash, which cannot be safely passed through cmd.exe /c: {}", + arg + ), + ), + } +} + fn write_temp_settings_file( temp_dir: &Path, provider_id: &str, diff --git a/src-tauri/src/cli/codex_temp_launch.rs b/src-tauri/src/cli/codex_temp_launch.rs index 5e1ef02e..f8317220 100644 --- a/src-tauri/src/cli/codex_temp_launch.rs +++ b/src-tauri/src/cli/codex_temp_launch.rs @@ -11,9 +11,6 @@ use crate::error::AppError; use crate::provider::Provider; use serde_json::Value; -#[cfg(windows)] -use windows_sys::Win32::System::Threading::TerminateProcess; - #[derive(Debug, Clone)] pub(crate) struct PreparedCodexLaunch { pub(crate) executable: PathBuf, @@ -116,11 +113,9 @@ pub(crate) fn exec_prepared_codex( native_args: &[OsString], ) -> Result<(), AppError> { use crate::cli::windows_temp_launch::{ - build_env_block_with_override, is_cmd_shim, Job, ScopedConsoleCtrlHandler, - spawn_suspended_createprocessw, wait_for_child, + build_env_block_with_override, is_cmd_shim, run_suspended_child, + ScopedConsoleCtrlHandler, }; - use windows_sys::Win32::Foundation::CloseHandle; - use windows_sys::Win32::System::Threading::ResumeThread; let _ctrl_guard = ScopedConsoleCtrlHandler::install()?; @@ -139,46 +134,12 @@ pub(crate) fn exec_prepared_codex( Some(program.as_path()) }; - let (process_handle, thread_handle) = - spawn_suspended_createprocessw(&program, &args, Some(&env_block), application_name)?; - - let job = match Job::create_with_kill_on_close() { - Ok(job) => job, - Err(e) => { - unsafe { - let _ = TerminateProcess(process_handle, 1); - CloseHandle(thread_handle); - CloseHandle(process_handle); - } - return Err(e); - } - }; - - if let Err(e) = job.try_assign(process_handle) { - log::warn!("{}", AppError::windows_job_assign_failed_fallback(&e)); - } - - let resume_result = unsafe { ResumeThread(thread_handle) }; - if resume_result == u32::MAX { - let code = unsafe { windows_sys::Win32::Foundation::GetLastError() }; - unsafe { - let _ = TerminateProcess(process_handle, 1); - CloseHandle(thread_handle); - CloseHandle(process_handle); - } - return Err(AppError::windows_resume_thread_failed(code)); - } - - unsafe { CloseHandle(thread_handle) }; - - let exit_code = match wait_for_child(process_handle) { - Ok(code) => code, - Err(e) => { - unsafe { CloseHandle(process_handle) }; - return Err(e); - } - }; - unsafe { CloseHandle(process_handle) }; + let exit_code = run_suspended_child( + &program, + &args, + Some(&env_block), + application_name, + )?; if exit_code != 0 { return Err(AppError::localized( @@ -208,56 +169,18 @@ fn build_command_windows( prepared: &PreparedCodexLaunch, native_args: &[OsString], ) -> Result<(std::path::PathBuf, Vec), AppError> { - use crate::cli::windows_temp_launch::{arg_requires_cmd_quote, is_cmd_shim}; + use crate::cli::windows_temp_launch::{is_cmd_shim, validate_cmd_arg}; if is_cmd_shim(&prepared.executable) { - // cmd.exe expands %VAR% and !VAR! (delayed expansion) even inside - // double quotes. There is no standard escape for these in a /c - // command line. Without refactoring to bypass cmd.exe /c entirely - // (e.g. parse the .cmd shim and invoke the underlying binary - // directly), this expansion cannot be fully avoided. Log a warning - // so users are aware. - // - // cmd.exe does not treat backslash as a quote escape, so a literal - // double quote inside an arg cannot be safely escaped — reject. A - // trailing backslash only becomes unsafe when the arg itself would - // be wrapped in `"..."` by cmd quoting, because then the `\` would - // escape the closing quote. Plain paths like `C:\work\` need no - // quoting and pass through verbatim. + // Validate internally-constructed arguments that also flow through + // cmd.exe /c (executable path). + if let Err(e) = validate_cmd_arg(&prepared.executable.to_string_lossy()) { + return Err(cmd_arg_error_to_app_error("codex", e)); + } + for arg in native_args { - let s = arg.to_string_lossy(); - if s.contains('%') || s.contains('!') { - log::warn!( - target: "codex_temp_launch", - "Native arg contains % or ! which cmd.exe may expand: {}", - s - ); - } - if s.contains('"') { - return Err(AppError::localized( - "codex.temp_launch_unsafe_cmd_quote", - format!( - "参数包含双引号,无法安全地通过 cmd.exe /c 传递: {}", - s - ), - format!( - "Native arg contains a double quote which cannot be safely passed through cmd.exe /c: {}", - s - ), - )); - } - if s.ends_with('\\') && arg_requires_cmd_quote(&s) { - return Err(AppError::localized( - "codex.temp_launch_unsafe_cmd_trailing_backslash", - format!( - "参数同时需要 cmd.exe 加引号且以反斜杠结尾,无法安全传递: {}", - s - ), - format!( - "Native arg both requires cmd.exe quoting and ends with a backslash, which cannot be safely passed through cmd.exe /c: {}", - s - ), - )); + if let Err(e) = validate_cmd_arg(&arg.to_string_lossy()) { + return Err(cmd_arg_error_to_app_error("codex", e)); } } let mut args = vec![OsString::from("/c"), OsString::from(&prepared.executable)]; @@ -268,6 +191,32 @@ fn build_command_windows( } } +#[cfg(windows)] +fn cmd_arg_error_to_app_error(_app_label: &str, err: crate::cli::windows_temp_launch::CmdArgError) -> AppError { + use crate::cli::windows_temp_launch::CmdArgError; + match err { + CmdArgError::DoubleQuote(arg) => AppError::localized( + "codex.temp_launch_unsafe_cmd_quote", + format!("参数包含双引号,无法安全地通过 cmd.exe /c 传递: {}", arg), + format!( + "Native arg contains a double quote which cannot be safely passed through cmd.exe /c: {}", + arg + ), + ), + CmdArgError::UnsafeTrailingBackslash(arg) => AppError::localized( + "codex.temp_launch_unsafe_cmd_trailing_backslash", + format!( + "参数同时需要 cmd.exe 加引号且以反斜杠结尾,无法安全传递: {}", + arg + ), + format!( + "Native arg both requires cmd.exe quoting and ends with a backslash, which cannot be safely passed through cmd.exe /c: {}", + arg + ), + ), + } +} + fn write_temp_codex_home(temp_dir: &Path, provider: &Provider) -> Result { write_temp_codex_home_with(temp_dir, provider, finalize_temp_codex_home) } @@ -333,6 +282,9 @@ where let codex_home = temp_dir.join(dir_name); let write_result = (|| { + #[cfg(windows)] + crate::cli::windows_temp_launch::create_secret_dir_all_with_acl(&codex_home)?; + #[cfg(not(windows))] fs::create_dir_all(&codex_home).map_err(|err| AppError::io(&codex_home, err))?; finalize(&codex_home)?; diff --git a/src-tauri/src/cli/windows_temp_launch.rs b/src-tauri/src/cli/windows_temp_launch.rs index 1b4ceb73..ca157166 100644 --- a/src-tauri/src/cli/windows_temp_launch.rs +++ b/src-tauri/src/cli/windows_temp_launch.rs @@ -1,5 +1,5 @@ use std::ffi::{OsStr, OsString}; -use std::fs::{File, OpenOptions}; +use std::fs::File; use std::path::Path; use crate::error::AppError; @@ -19,7 +19,7 @@ use windows_sys::Win32::System::JobObjects::{ }; #[cfg(windows)] use windows_sys::Win32::System::Threading::{ - CreateProcessW, GetExitCodeProcess, ResumeThread, WaitForSingleObject, + CreateProcessW, GetExitCodeProcess, WaitForSingleObject, CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, INFINITE, PROCESS_INFORMATION, STARTUPINFOW, }; @@ -51,6 +51,49 @@ pub(crate) fn arg_requires_cmd_quote(s: &str) -> bool { || s.chars().any(|c| CMD_SPECIAL.contains(&c)) } +/// Result of validating a single argument for `cmd.exe /c` safety. +#[cfg(windows)] +#[derive(Debug, Clone)] +pub(crate) enum CmdArgError { + DoubleQuote(String), + UnsafeTrailingBackslash(String), +} + +impl std::fmt::Display for CmdArgError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CmdArgError::DoubleQuote(arg) => { + write!(f, "double quote in cmd.exe arg: {}", arg) + } + CmdArgError::UnsafeTrailingBackslash(arg) => { + write!(f, "trailing backslash in quoted cmd.exe arg: {}", arg) + } + } + } +} + +/// Validates a single argument for safety when passed through `cmd.exe /c`. +/// Returns `Ok(())` if safe, `Err(CmdArgError)` if the argument contains a +/// double quote or an unsafe trailing backslash. Prints a warning to stderr +/// when `%` or `!` are present (which cmd.exe may expand) so users are +/// notified even when the log level is set to `error`. +#[cfg(windows)] +pub(crate) fn validate_cmd_arg(arg: &str) -> Result<(), CmdArgError> { + if arg.contains('%') || arg.contains('!') { + eprintln!( + "cc-switch warning: argument contains % or ! which cmd.exe may expand: {}", + arg + ); + } + if arg.contains('"') { + return Err(CmdArgError::DoubleQuote(arg.to_string())); + } + if arg.ends_with('\\') && arg_requires_cmd_quote(arg) { + return Err(CmdArgError::UnsafeTrailingBackslash(arg.to_string())); + } + Ok(()) +} + #[cfg(windows)] pub(crate) fn quote_windows_arg(arg: &str) -> String { if arg.is_empty() { @@ -171,22 +214,26 @@ pub(crate) fn build_windows_command_line(program: &OsStr, args: &[OsString]) -> pub(crate) fn build_env_block_with_override(key: &str, value: &OsStr) -> Vec { use std::os::windows::ffi::OsStrExt; + let mut vars: Vec<(std::ffi::OsString, std::ffi::OsString)> = std::env::vars_os() + .filter(|(k, _)| !k.to_string_lossy().eq_ignore_ascii_case(key)) + .collect(); + // Add our override + vars.push((std::ffi::OsString::from(key), value.to_os_string())); + // Windows docs say caller-supplied environment blocks should be sorted. + // Sort by key case-insensitively to match Windows conventions. + vars.sort_by(|(a, _), (b, _)| { + a.to_string_lossy() + .to_lowercase() + .cmp(&b.to_string_lossy().to_lowercase()) + }); + let mut result = Vec::new(); - for (k, v) in std::env::vars_os() { - // Windows environment variable names are case-insensitive. - if k.to_string_lossy().eq_ignore_ascii_case(key) { - continue; - } + for (k, v) in vars { result.extend(k.encode_wide()); result.push(b'=' as u16); result.extend(v.encode_wide()); result.push(0); } - // Add our override - result.extend(key.encode_utf16()); - result.push(b'=' as u16); - result.extend(value.encode_wide()); - result.push(0); // Double-null terminate the block result.push(0); result @@ -370,74 +417,130 @@ pub(crate) fn wait_for_child(process_handle: HANDLE) -> Result { } } +/// Shared Windows child-process lifecycle: spawn suspended, create a Job +/// Object with KILL_ON_JOB_CLOSE, assign the process, resume the main +/// thread, wait for termination, and return the exit code. All handles are +/// cleaned up regardless of success or failure. +#[cfg(windows)] +pub(crate) fn run_suspended_child( + program: &std::path::Path, + args: &[OsString], + env_block: Option<&[u16]>, + application_name: Option<&std::path::Path>, +) -> Result { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::ResumeThread; + + let (h_process, h_thread) = + spawn_suspended_createprocessw(program, args, env_block, application_name)?; + + let job = match Job::create_with_kill_on_close() { + Ok(job) => job, + Err(e) => { + unsafe { + let _ = windows_sys::Win32::System::Threading::TerminateProcess(h_process, 1); + CloseHandle(h_thread); + CloseHandle(h_process); + } + return Err(e); + } + }; + + if let Err(e) = job.try_assign(h_process) { + log::warn!(target: "windows.job_assign_failed_fallback", "{}", e); + } + + let resume_result = unsafe { ResumeThread(h_thread) }; + if resume_result == u32::MAX { + let code = unsafe { windows_sys::Win32::Foundation::GetLastError() }; + unsafe { + let _ = windows_sys::Win32::System::Threading::TerminateProcess(h_process, 1); + CloseHandle(h_thread); + CloseHandle(h_process); + } + return Err(AppError::windows_resume_thread_failed(code)); + } + + unsafe { + CloseHandle(h_thread); + } + + let exit_code = match wait_for_child(h_process) { + Ok(code) => code, + Err(e) => { + unsafe { CloseHandle(h_process) }; + return Err(e); + } + }; + unsafe { CloseHandle(h_process) }; + + Ok(exit_code) +} + // ── ACL / file security ────────────────────────────────────────────── #[cfg(windows)] -pub(crate) fn restrict_to_owner(path: &Path, inherit: bool) -> Result<(), AppError> { - use std::ffi::OsStr; - use std::os::windows::ffi::OsStrExt; - use windows_sys::Win32::Foundation::{CloseHandle, ERROR_SUCCESS, HANDLE}; - use windows_sys::Win32::Security::Authorization::{ - SetNamedSecurityInfoW, SE_FILE_OBJECT, - }; +fn get_current_user_sid() -> Result, AppError> { + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; use windows_sys::Win32::Security::{ - ACL, AddAccessAllowedAceEx, DACL_SECURITY_INFORMATION, - GetLengthSid, GetTokenInformation, InitializeAcl, - PROTECTED_DACL_SECURITY_INFORMATION, - TOKEN_QUERY, TOKEN_USER, TokenUser, + GetLengthSid, GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser, }; use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; - const NO_INHERITANCE: u32 = 0; - const OBJECT_INHERIT_ACE: u32 = 0x1; - const CONTAINER_INHERIT_ACE: u32 = 0x2; - const FILE_ALL_ACCESS: u32 = 0x1F01FF; - const ACL_REVISION: u32 = 2; - - // Open current process token to get the user SID let mut token: HANDLE = std::ptr::null_mut(); let result = unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }; if result == 0 { - return Err(AppError::io(path, std::io::Error::last_os_error())); + return Err(AppError::io("token", std::io::Error::last_os_error())); } - // Get token user info (first call to get size) let mut size = 0u32; - unsafe { - GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut size); - } + unsafe { GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut size); } let mut buffer = vec![0u8; size as usize]; let result = unsafe { - GetTokenInformation( - token, - TokenUser, - buffer.as_mut_ptr() as *mut _, - size, - &mut size, - ) + GetTokenInformation(token, TokenUser, buffer.as_mut_ptr() as *mut _, size, &mut size) }; if result == 0 { unsafe { CloseHandle(token) }; - return Err(AppError::io(path, std::io::Error::last_os_error())); + return Err(AppError::io("token", std::io::Error::last_os_error())); } let token_user = unsafe { &*(buffer.as_ptr() as *const TOKEN_USER) }; - let user_sid = token_user.User.Sid; + let sid = token_user.User.Sid; + let sid_len = unsafe { GetLengthSid(sid) }; + + let mut sid_buffer = vec![0u8; sid_len as usize]; + unsafe { + std::ptr::copy_nonoverlapping(sid as *const u8, sid_buffer.as_mut_ptr(), sid_len as usize); + } unsafe { CloseHandle(token) }; + Ok(sid_buffer) +} + +#[cfg(windows)] +fn build_owner_only_acl(inherit: bool) -> Result, AppError> { + use windows_sys::Win32::Security::{ + ACL, AddAccessAllowedAceEx, GetLengthSid, InitializeAcl, + }; + + let sid = get_current_user_sid()?; + let sid_ptr = sid.as_ptr() as *mut _; - let sid_len = unsafe { GetLengthSid(user_sid) }; + const NO_INHERITANCE: u32 = 0; + const OBJECT_INHERIT_ACE: u32 = 0x1; + const CONTAINER_INHERIT_ACE: u32 = 0x2; + const FILE_ALL_ACCESS: u32 = 0x1F01FF; + const ACL_REVISION: u32 = 2; - // ACL size = ACL header + ACCESS_ALLOWED_ACE without SidStart + SID length - // ACL header = 8 bytes, ACE header+Mask = 8 bytes, SidStart = 4 bytes + let sid_len = unsafe { GetLengthSid(sid_ptr) }; let acl_size = (std::mem::size_of::() + 8 + sid_len as usize) as u32; let mut acl_buffer = vec![0u8; acl_size as usize]; let acl = acl_buffer.as_mut_ptr() as *mut ACL; let result = unsafe { InitializeAcl(acl, acl_size, ACL_REVISION) }; if result == 0 { - return Err(AppError::io(path, std::io::Error::last_os_error())); + return Err(AppError::io("acl", std::io::Error::last_os_error())); } let ace_flags = if inherit { @@ -447,12 +550,30 @@ pub(crate) fn restrict_to_owner(path: &Path, inherit: bool) -> Result<(), AppErr }; let result = unsafe { - AddAccessAllowedAceEx(acl, ACL_REVISION, ace_flags, FILE_ALL_ACCESS, user_sid) + AddAccessAllowedAceEx(acl, ACL_REVISION, ace_flags, FILE_ALL_ACCESS, sid_ptr) }; if result == 0 { - return Err(AppError::io(path, std::io::Error::last_os_error())); + return Err(AppError::io("acl", std::io::Error::last_os_error())); } + Ok(acl_buffer) +} + +#[cfg(windows)] +pub(crate) fn restrict_to_owner(path: &Path, inherit: bool) -> Result<(), AppError> { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::ERROR_SUCCESS; + use windows_sys::Win32::Security::Authorization::{ + SetNamedSecurityInfoW, SE_FILE_OBJECT, + }; + use windows_sys::Win32::Security::{ + DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, + }; + + let acl_buffer = build_owner_only_acl(inherit)?; + let acl = acl_buffer.as_ptr() as *mut _; + let path_wide: Vec = OsStr::new(path) .encode_wide() .chain(std::iter::once(0)) @@ -477,17 +598,128 @@ pub(crate) fn restrict_to_owner(path: &Path, inherit: bool) -> Result<(), AppErr Ok(()) } +/// Create a file atomically with an owner-only DACL, eliminating the +/// TOCTOU window between creation and ACL restriction. #[cfg(windows)] -pub(crate) fn create_secret_temp_file(path: &Path) -> Result { - let file = OpenOptions::new() - .write(true) - .create_new(true) - .open(path) - .map_err(|err| AppError::io(path, err))?; - restrict_to_owner(path, false)?; +pub(crate) fn create_secret_file_with_acl(path: &Path) -> Result { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use std::os::windows::io::FromRawHandle; + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::Security::{ + InitializeSecurityDescriptor, SetSecurityDescriptorDacl, SECURITY_DESCRIPTOR, + SECURITY_ATTRIBUTES, + }; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_READ, FILE_GENERIC_WRITE, + FILE_SHARE_READ, FILE_SHARE_WRITE, CREATE_NEW, + }; + + let mut acl_buffer = build_owner_only_acl(false)?; + let acl = acl_buffer.as_mut_ptr() as *mut _; + + let mut sd: SECURITY_DESCRIPTOR = unsafe { std::mem::zeroed() }; + let result = unsafe { InitializeSecurityDescriptor(&mut sd as *mut _ as *mut _, 1) }; + if result == 0 { + return Err(AppError::io(path, std::io::Error::last_os_error())); + } + + let result = unsafe { SetSecurityDescriptorDacl(&mut sd as *mut _ as *mut _, 1, acl, 0) }; + if result == 0 { + return Err(AppError::io(path, std::io::Error::last_os_error())); + } + + let sa = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: &mut sd as *mut _ as *mut _, + bInheritHandle: 0, + }; + + let path_wide: Vec = OsStr::new(path) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let handle = unsafe { + CreateFileW( + path_wide.as_ptr(), + FILE_GENERIC_READ | FILE_GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + &sa, + CREATE_NEW, + FILE_ATTRIBUTE_NORMAL, + std::ptr::null_mut(), + ) + }; + + if handle == INVALID_HANDLE_VALUE { + return Err(AppError::io(path, std::io::Error::last_os_error())); + } + + let file = unsafe { File::from_raw_handle(handle as _) }; Ok(file) } +/// Create a directory atomically with an owner-only DACL, eliminating the +/// TOCTOU window between creation and ACL restriction. +#[cfg(windows)] +pub(crate) fn create_secret_dir_with_acl(path: &Path) -> Result<(), AppError> { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Security::{ + InitializeSecurityDescriptor, SetSecurityDescriptorDacl, SECURITY_DESCRIPTOR, + SECURITY_ATTRIBUTES, + }; + use windows_sys::Win32::Storage::FileSystem::CreateDirectoryW; + + let mut acl_buffer = build_owner_only_acl(true)?; + let acl = acl_buffer.as_mut_ptr() as *mut _; + + let mut sd: SECURITY_DESCRIPTOR = unsafe { std::mem::zeroed() }; + let result = unsafe { InitializeSecurityDescriptor(&mut sd as *mut _ as *mut _, 1) }; + if result == 0 { + return Err(AppError::io(path, std::io::Error::last_os_error())); + } + + let result = unsafe { SetSecurityDescriptorDacl(&mut sd as *mut _ as *mut _, 1, acl, 0) }; + if result == 0 { + return Err(AppError::io(path, std::io::Error::last_os_error())); + } + + let sa = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: &mut sd as *mut _ as *mut _, + bInheritHandle: 0, + }; + + let path_wide: Vec = OsStr::new(path) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let result = unsafe { CreateDirectoryW(path_wide.as_ptr(), &sa) }; + if result == 0 { + return Err(AppError::io(path, std::io::Error::last_os_error())); + } + + Ok(()) +} + +#[cfg(windows)] +pub(crate) fn create_secret_temp_file(path: &Path) -> Result { + create_secret_file_with_acl(path) +} + +#[cfg(windows)] +pub(crate) fn create_secret_dir_all_with_acl(path: &Path) -> Result<(), AppError> { + // Ensure parent directories exist with default permissions. + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|err| AppError::io(parent, err))?; + } + // Create the leaf directory atomically with owner-only ACL. + create_secret_dir_with_acl(path) +} + #[cfg(test)] mod tests { use super::*; @@ -586,6 +818,7 @@ mod tests { #[cfg(windows)] #[test] fn windows_smoke_test_spawn_job_wait_exit_code() { + use windows_sys::Win32::System::Threading::ResumeThread; let (h_process, h_thread) = spawn_suspended_createprocessw( std::path::Path::new("cmd.exe"), &[ From c6b77e5246f47baa99100ddd0489cb3f157ef2bc Mon Sep 17 00:00:00 2001 From: aloneatwar <2318583515@qq.com> Date: Mon, 27 Apr 2026 07:46:20 +0800 Subject: [PATCH 10/16] fix(windows): address codex review blocking issues round 2 - Make cmd.exe % and ! expansion hard errors instead of warnings. Adding CmdArgError::Percent and CmdArgError::Exclamation variants; validate_cmd_arg now rejects these characters to prevent real command-injection paths through cmd.exe /c (reproduced by codex). - Set SE_DACL_PROTECTED on security descriptors created by create_secret_file_with_acl and create_secret_dir_with_acl. This blocks inheritable ACEs from the parent directory, eliminating the TOCTOU window where inherited permissions could read secret temp files before restrict_to_owner was called. - Add automated test create_secret_file_with_acl_has_protected_dacl that reads the security descriptor back and verifies the DACL is protected and present. - Add automated test validate_cmd_arg_rejects_percent_and_exclamation. Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/cli/claude_temp_launch.rs | 16 +++ src-tauri/src/cli/codex_temp_launch.rs | 16 +++ src-tauri/src/cli/windows_temp_launch.rs | 138 ++++++++++++++++++++--- 3 files changed, 157 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/cli/claude_temp_launch.rs b/src-tauri/src/cli/claude_temp_launch.rs index 759d12f1..63cf8899 100644 --- a/src-tauri/src/cli/claude_temp_launch.rs +++ b/src-tauri/src/cli/claude_temp_launch.rs @@ -226,6 +226,22 @@ fn cmd_arg_error_to_app_error(_app_label: &str, err: crate::cli::windows_temp_la arg ), ), + CmdArgError::Percent(arg) => AppError::localized( + "claude.temp_launch_unsafe_cmd_percent", + format!("参数包含百分号,cmd.exe 会将其作为环境变量扩展,无法安全传递: {}", arg), + format!( + "Native arg contains a percent sign which cmd.exe expands as an environment variable, cannot be safely passed through cmd.exe /c: {}", + arg + ), + ), + CmdArgError::Exclamation(arg) => AppError::localized( + "claude.temp_launch_unsafe_cmd_exclamation", + format!("参数包含感叹号,cmd.exe 会将其作为延迟环境变量扩展,无法安全传递: {}", arg), + format!( + "Native arg contains an exclamation mark which cmd.exe expands as a delayed environment variable, cannot be safely passed through cmd.exe /c: {}", + arg + ), + ), } } diff --git a/src-tauri/src/cli/codex_temp_launch.rs b/src-tauri/src/cli/codex_temp_launch.rs index f8317220..4c5efe6c 100644 --- a/src-tauri/src/cli/codex_temp_launch.rs +++ b/src-tauri/src/cli/codex_temp_launch.rs @@ -214,6 +214,22 @@ fn cmd_arg_error_to_app_error(_app_label: &str, err: crate::cli::windows_temp_la arg ), ), + CmdArgError::Percent(arg) => AppError::localized( + "codex.temp_launch_unsafe_cmd_percent", + format!("参数包含百分号,cmd.exe 会将其作为环境变量扩展,无法安全传递: {}", arg), + format!( + "Native arg contains a percent sign which cmd.exe expands as an environment variable, cannot be safely passed through cmd.exe /c: {}", + arg + ), + ), + CmdArgError::Exclamation(arg) => AppError::localized( + "codex.temp_launch_unsafe_cmd_exclamation", + format!("参数包含感叹号,cmd.exe 会将其作为延迟环境变量扩展,无法安全传递: {}", arg), + format!( + "Native arg contains an exclamation mark which cmd.exe expands as a delayed environment variable, cannot be safely passed through cmd.exe /c: {}", + arg + ), + ), } } diff --git a/src-tauri/src/cli/windows_temp_launch.rs b/src-tauri/src/cli/windows_temp_launch.rs index ca157166..fea853a6 100644 --- a/src-tauri/src/cli/windows_temp_launch.rs +++ b/src-tauri/src/cli/windows_temp_launch.rs @@ -57,6 +57,8 @@ pub(crate) fn arg_requires_cmd_quote(s: &str) -> bool { pub(crate) enum CmdArgError { DoubleQuote(String), UnsafeTrailingBackslash(String), + Percent(String), + Exclamation(String), } impl std::fmt::Display for CmdArgError { @@ -68,22 +70,28 @@ impl std::fmt::Display for CmdArgError { CmdArgError::UnsafeTrailingBackslash(arg) => { write!(f, "trailing backslash in quoted cmd.exe arg: {}", arg) } + CmdArgError::Percent(arg) => { + write!(f, "percent sign in cmd.exe arg (cmd.exe expands env vars): {}", arg) + } + CmdArgError::Exclamation(arg) => { + write!(f, "exclamation mark in cmd.exe arg (cmd.exe expands delayed vars): {}", arg) + } } } } /// Validates a single argument for safety when passed through `cmd.exe /c`. -/// Returns `Ok(())` if safe, `Err(CmdArgError)` if the argument contains a -/// double quote or an unsafe trailing backslash. Prints a warning to stderr -/// when `%` or `!` are present (which cmd.exe may expand) so users are -/// notified even when the log level is set to `error`. +/// Returns `Ok(())` if safe, `Err(CmdArgError)` if the argument contains +/// characters that cmd.exe treats specially: double quotes, percent signs +/// (environment variable expansion), exclamation marks (delayed expansion), +/// or an unsafe trailing backslash inside a quoted segment. #[cfg(windows)] pub(crate) fn validate_cmd_arg(arg: &str) -> Result<(), CmdArgError> { - if arg.contains('%') || arg.contains('!') { - eprintln!( - "cc-switch warning: argument contains % or ! which cmd.exe may expand: {}", - arg - ); + if arg.contains('%') { + return Err(CmdArgError::Percent(arg.to_string())); + } + if arg.contains('!') { + return Err(CmdArgError::Exclamation(arg.to_string())); } if arg.contains('"') { return Err(CmdArgError::DoubleQuote(arg.to_string())); @@ -607,8 +615,8 @@ pub(crate) fn create_secret_file_with_acl(path: &Path) -> Result use std::os::windows::io::FromRawHandle; use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; use windows_sys::Win32::Security::{ - InitializeSecurityDescriptor, SetSecurityDescriptorDacl, SECURITY_DESCRIPTOR, - SECURITY_ATTRIBUTES, + InitializeSecurityDescriptor, SetSecurityDescriptorControl, SetSecurityDescriptorDacl, + SECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, SE_DACL_PROTECTED, }; use windows_sys::Win32::Storage::FileSystem::{ CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_READ, FILE_GENERIC_WRITE, @@ -629,6 +637,17 @@ pub(crate) fn create_secret_file_with_acl(path: &Path) -> Result return Err(AppError::io(path, std::io::Error::last_os_error())); } + let result = unsafe { + SetSecurityDescriptorControl( + &mut sd as *mut _ as *mut _, + SE_DACL_PROTECTED, + SE_DACL_PROTECTED, + ) + }; + if result == 0 { + return Err(AppError::io(path, std::io::Error::last_os_error())); + } + let sa = SECURITY_ATTRIBUTES { nLength: std::mem::size_of::() as u32, lpSecurityDescriptor: &mut sd as *mut _ as *mut _, @@ -667,8 +686,8 @@ pub(crate) fn create_secret_dir_with_acl(path: &Path) -> Result<(), AppError> { use std::ffi::OsStr; use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::Security::{ - InitializeSecurityDescriptor, SetSecurityDescriptorDacl, SECURITY_DESCRIPTOR, - SECURITY_ATTRIBUTES, + InitializeSecurityDescriptor, SetSecurityDescriptorControl, SetSecurityDescriptorDacl, + SECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, SE_DACL_PROTECTED, }; use windows_sys::Win32::Storage::FileSystem::CreateDirectoryW; @@ -686,6 +705,17 @@ pub(crate) fn create_secret_dir_with_acl(path: &Path) -> Result<(), AppError> { return Err(AppError::io(path, std::io::Error::last_os_error())); } + let result = unsafe { + SetSecurityDescriptorControl( + &mut sd as *mut _ as *mut _, + SE_DACL_PROTECTED, + SE_DACL_PROTECTED, + ) + }; + if result == 0 { + return Err(AppError::io(path, std::io::Error::last_os_error())); + } + let sa = SECURITY_ATTRIBUTES { nLength: std::mem::size_of::() as u32, lpSecurityDescriptor: &mut sd as *mut _ as *mut _, @@ -796,6 +826,23 @@ mod tests { assert!(!arg_requires_cmd_quote("--project-dir=C:\\tmp\\")); } + #[cfg(windows)] + #[test] + fn validate_cmd_arg_rejects_percent_and_exclamation() { + assert!( + matches!(validate_cmd_arg("foo%bar"), Err(CmdArgError::Percent(s)) if s == "foo%bar") + ); + assert!( + matches!(validate_cmd_arg("foo!bar"), Err(CmdArgError::Exclamation(s)) if s == "foo!bar") + ); + assert!( + matches!(validate_cmd_arg("%CCSWITCH_TEST%"), Err(CmdArgError::Percent(s)) if s == "%CCSWITCH_TEST%") + ); + // Plain args should still pass + assert!(validate_cmd_arg("plain").is_ok()); + assert!(validate_cmd_arg("C:\\path\\file.exe").is_ok()); + } + #[cfg(windows)] #[test] fn build_windows_command_line_quotes_after_c_for_cmd() { @@ -813,6 +860,71 @@ mod tests { assert!(s.contains("normal")); } + /// Verify that `create_secret_file_with_acl` creates a file with an + /// owner-only DACL and no inherited ACEs (SE_DACL_PROTECTED). + #[cfg(windows)] + #[test] + fn create_secret_file_with_acl_has_protected_dacl() { + use std::ffi::OsStr; + use std::io::Write; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Security::Authorization::{ + GetNamedSecurityInfoW, SE_FILE_OBJECT, + }; + use windows_sys::Win32::Security::{ + GetSecurityDescriptorControl, GetSecurityDescriptorDacl, + DACL_SECURITY_INFORMATION, SE_DACL_PROTECTED, + }; + + let temp_dir = tempfile::tempdir().unwrap(); + let path = temp_dir.path().join("secret.txt"); + let mut file = create_secret_file_with_acl(&path).unwrap(); + file.write_all(b"secret").unwrap(); + drop(file); + + let path_wide: Vec = + OsStr::new(&path).encode_wide().chain(std::iter::once(0)).collect(); + + let mut psec_desc: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + let result = unsafe { + GetNamedSecurityInfoW( + path_wide.as_ptr() as *mut _, + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut psec_desc, + ) + }; + assert_eq!(result, 0, "GetNamedSecurityInfoW should succeed"); + + let mut control: u16 = 0; + let mut revision: u32 = 0; + let result = unsafe { + GetSecurityDescriptorControl(psec_desc as *mut _, &mut control, &mut revision) + }; + assert_eq!(result, 1, "GetSecurityDescriptorControl should succeed"); + assert!( + control & SE_DACL_PROTECTED as u16 != 0, + "DACL should be protected (no inherited ACEs)" + ); + + let mut present: i32 = 0; + let mut defaulted: i32 = 0; + let mut pacl: *mut windows_sys::Win32::Security::ACL = std::ptr::null_mut(); + let result = unsafe { + GetSecurityDescriptorDacl(psec_desc as *mut _, &mut present, &mut pacl, &mut defaulted) + }; + assert_eq!(result, 1, "GetSecurityDescriptorDacl should succeed"); + assert!(present != 0, "DACL should be present"); + assert!(!pacl.is_null(), "DACL pointer should not be null"); + // psec_desc is allocated by GetNamedSecurityInfoW and would normally + // be freed with LocalFree; we skip the free in this test since the + // process exit will reclaim the memory. + } + /// Smoke test: spawn a real child process via the shared Windows path, /// assign it to a Job Object, resume it, and verify the exit code. #[cfg(windows)] From e0a0f8d9169442e466ebef8f5206eaef33289629 Mon Sep 17 00:00:00 2001 From: aloneatwar <2318583515@qq.com> Date: Mon, 27 Apr 2026 07:53:50 +0800 Subject: [PATCH 11/16] fix(unix): restore OpenOptions import for Unix-only temp file helpers The previous commit removed OpenOptions from the top-level imports, breaking Unix compilation because create_secret_temp_file on Unix still uses OpenOptions::new(). Gate the import behind #[cfg(unix)] to avoid Windows unused-import warnings while keeping Unix builds working. Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/cli/claude_temp_launch.rs | 2 ++ src-tauri/src/cli/codex_temp_launch.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src-tauri/src/cli/claude_temp_launch.rs b/src-tauri/src/cli/claude_temp_launch.rs index 63cf8899..2bcfe4db 100644 --- a/src-tauri/src/cli/claude_temp_launch.rs +++ b/src-tauri/src/cli/claude_temp_launch.rs @@ -1,5 +1,7 @@ use std::ffi::OsString; use std::fs::{self, File}; +#[cfg(unix)] +use std::fs::OpenOptions; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; diff --git a/src-tauri/src/cli/codex_temp_launch.rs b/src-tauri/src/cli/codex_temp_launch.rs index 4c5efe6c..01e05d35 100644 --- a/src-tauri/src/cli/codex_temp_launch.rs +++ b/src-tauri/src/cli/codex_temp_launch.rs @@ -1,5 +1,7 @@ use std::ffi::OsString; use std::fs::{self, File}; +#[cfg(unix)] +use std::fs::OpenOptions; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; From 93da898a427bb0ba72df474e7c01fe49f62fc643 Mon Sep 17 00:00:00 2001 From: aloneatwar <2318583515@qq.com> Date: Mon, 27 Apr 2026 08:01:49 +0800 Subject: [PATCH 12/16] fix(windows): resolve cmd.exe to absolute path to prevent hijacking When launching .cmd/.bat shims, both Claude and Codex wrappers were passing unqualified 'cmd.exe' as lpApplicationName (or NULL), which lets CreateProcessW search the current directory first. A rogue cmd.exe in the workspace could be executed instead of the system binary. Add resolve_system_cmd_exe() helper that uses which::which('cmd.exe') with a ComSpec fallback, and pass the absolute path as lpApplicationName while keeping 'cmd.exe' in the command line string so build_windows_command_line still recognizes it for proper quoting. Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/cli/claude_temp_launch.rs | 5 ++++- src-tauri/src/cli/codex_temp_launch.rs | 12 ++++++------ src-tauri/src/cli/windows_temp_launch.rs | 20 +++++++++++++++++++- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/cli/claude_temp_launch.rs b/src-tauri/src/cli/claude_temp_launch.rs index 2bcfe4db..29e439be 100644 --- a/src-tauri/src/cli/claude_temp_launch.rs +++ b/src-tauri/src/cli/claude_temp_launch.rs @@ -194,7 +194,10 @@ fn build_claude_command_windows( OsString::from(&prepared.settings_path), ]; args.extend_from_slice(native_args); - Ok((PathBuf::from("cmd.exe"), args, None)) + // Pass the absolute system cmd.exe as lpApplicationName so + // CreateProcessW does not search the current directory. + let cmd_exe = crate::cli::windows_temp_launch::resolve_system_cmd_exe()?; + Ok((PathBuf::from("cmd.exe"), args, Some(cmd_exe))) } else { let mut args = vec![ OsString::from("--settings"), diff --git a/src-tauri/src/cli/codex_temp_launch.rs b/src-tauri/src/cli/codex_temp_launch.rs index 01e05d35..17ebe883 100644 --- a/src-tauri/src/cli/codex_temp_launch.rs +++ b/src-tauri/src/cli/codex_temp_launch.rs @@ -125,13 +125,13 @@ pub(crate) fn exec_prepared_codex( let env_block = build_env_block_with_override("CODEX_HOME", prepared.codex_home.as_os_str()); - // CreateProcessW does not search PATH when lpApplicationName is non-NULL, - // so for the cmd.exe shim path (unqualified `cmd.exe`) we must pass NULL - // and let Windows resolve it from PATH. For the direct-binary path we - // pass the fully-resolved executable so the exact path is launched even - // if PATH later changes. + // Pass the absolute system cmd.exe as lpApplicationName so + // CreateProcessW does not search the current directory (which would + // allow executable hijacking). For direct binaries we already have + // the fully-resolved path. + let cmd_exe = crate::cli::windows_temp_launch::resolve_system_cmd_exe()?; let application_name: Option<&std::path::Path> = if is_cmd_shim(&prepared.executable) { - None + Some(cmd_exe.as_path()) } else { Some(program.as_path()) }; diff --git a/src-tauri/src/cli/windows_temp_launch.rs b/src-tauri/src/cli/windows_temp_launch.rs index fea853a6..a9ddb705 100644 --- a/src-tauri/src/cli/windows_temp_launch.rs +++ b/src-tauri/src/cli/windows_temp_launch.rs @@ -1,6 +1,6 @@ use std::ffi::{OsStr, OsString}; use std::fs::File; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::error::AppError; @@ -189,6 +189,24 @@ pub(crate) fn quote_windows_arg_for_cmd(arg: &str) -> String { result } +/// Resolve the system `cmd.exe` to an absolute path so `CreateProcessW` +/// does not search the current directory (which would allow executable +/// hijacking). Falls back to `%ComSpec%` if `which` fails. +#[cfg(windows)] +pub(crate) fn resolve_system_cmd_exe() -> Result { + which::which("cmd.exe").or_else(|_| { + std::env::var("ComSpec") + .map(PathBuf::from) + .map_err(|_| { + AppError::localized( + "windows.resolve_cmd_exe_failed", + "无法定位系统 cmd.exe 路径".to_string(), + "Could not locate system cmd.exe path.".to_string(), + ) + }) + }) +} + // ── command line construction ──────────────────────────────────────── #[cfg(windows)] From 6d602cdb7a7e0c370d6dc1d3d870f2ecd2feb63f Mon Sep 17 00:00:00 2001 From: aloneatwar <2318583515@qq.com> Date: Mon, 27 Apr 2026 08:10:47 +0800 Subject: [PATCH 13/16] fix(windows): resolve cmd.exe via GetSystemDirectoryW instead of PATH which::which and ComSpec are both environment-influenceable, so a hijacked PATH or ComSpec could still redirect .cmd/.bat launches to a rogue binary. Use GetSystemDirectoryW to ask the OS directly for the system directory, then append cmd.exe. This is the trusted path. Also avoid unconditionally resolving cmd.exe for direct .exe launches in the Codex wrapper; only resolve it when is_cmd_shim is true. Co-Authored-By: Claude Opus 4.7 --- src-tauri/Cargo.toml | 2 +- src-tauri/src/cli/codex_temp_launch.rs | 9 +++-- src-tauri/src/cli/windows_temp_launch.rs | 45 ++++++++++++++++-------- 3 files changed, 35 insertions(+), 21 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c093c4d1..2a624aca 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -90,7 +90,7 @@ tachyonfx = "0.25" [target.'cfg(target_os = "windows")'.dependencies] winreg = "0.52" self-replace = "1" -windows-sys = { version = "0.59", features = ["Win32_System_JobObjects", "Win32_System_Console", "Win32_System_Threading", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem"] } +windows-sys = { version = "0.59", features = ["Win32_System_JobObjects", "Win32_System_Console", "Win32_System_Threading", "Win32_System_SystemInformation", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem"] } # Optimize release binary size to help reduce AppImage footprint [profile.release] diff --git a/src-tauri/src/cli/codex_temp_launch.rs b/src-tauri/src/cli/codex_temp_launch.rs index 17ebe883..dbadc47d 100644 --- a/src-tauri/src/cli/codex_temp_launch.rs +++ b/src-tauri/src/cli/codex_temp_launch.rs @@ -129,18 +129,17 @@ pub(crate) fn exec_prepared_codex( // CreateProcessW does not search the current directory (which would // allow executable hijacking). For direct binaries we already have // the fully-resolved path. - let cmd_exe = crate::cli::windows_temp_launch::resolve_system_cmd_exe()?; - let application_name: Option<&std::path::Path> = if is_cmd_shim(&prepared.executable) { - Some(cmd_exe.as_path()) + let application_name: Option = if is_cmd_shim(&prepared.executable) { + Some(crate::cli::windows_temp_launch::resolve_system_cmd_exe()?) } else { - Some(program.as_path()) + Some(program.clone()) }; let exit_code = run_suspended_child( &program, &args, Some(&env_block), - application_name, + application_name.as_deref(), )?; if exit_code != 0 { diff --git a/src-tauri/src/cli/windows_temp_launch.rs b/src-tauri/src/cli/windows_temp_launch.rs index a9ddb705..da0f9888 100644 --- a/src-tauri/src/cli/windows_temp_launch.rs +++ b/src-tauri/src/cli/windows_temp_launch.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use crate::error::AppError; #[cfg(windows)] -use std::os::windows::ffi::OsStrExt; +use std::os::windows::ffi::{OsStrExt, OsStringExt}; #[cfg(windows)] use windows_sys::Win32::Foundation::{ CloseHandle, GetLastError, FALSE, HANDLE, INVALID_HANDLE_VALUE, TRUE, @@ -18,6 +18,8 @@ use windows_sys::Win32::System::JobObjects::{ SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, }; #[cfg(windows)] +use windows_sys::Win32::System::SystemInformation::GetSystemDirectoryW; +#[cfg(windows)] use windows_sys::Win32::System::Threading::{ CreateProcessW, GetExitCodeProcess, WaitForSingleObject, CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, INFINITE, PROCESS_INFORMATION, STARTUPINFOW, @@ -189,22 +191,35 @@ pub(crate) fn quote_windows_arg_for_cmd(arg: &str) -> String { result } -/// Resolve the system `cmd.exe` to an absolute path so `CreateProcessW` -/// does not search the current directory (which would allow executable -/// hijacking). Falls back to `%ComSpec%` if `which` fails. +/// Resolve the system `cmd.exe` to an absolute path via +/// `GetSystemDirectoryW` so `CreateProcessW` does not search the current +/// directory (which would allow executable hijacking). This is strictly more +/// trustworthy than `which` or `%ComSpec%` because it asks the OS for the +/// system directory directly. #[cfg(windows)] pub(crate) fn resolve_system_cmd_exe() -> Result { - which::which("cmd.exe").or_else(|_| { - std::env::var("ComSpec") - .map(PathBuf::from) - .map_err(|_| { - AppError::localized( - "windows.resolve_cmd_exe_failed", - "无法定位系统 cmd.exe 路径".to_string(), - "Could not locate system cmd.exe path.".to_string(), - ) - }) - }) + let mut buffer = vec![0u16; 512]; + let len = unsafe { GetSystemDirectoryW(buffer.as_mut_ptr(), buffer.len() as u32) }; + if len == 0 { + return Err(AppError::localized( + "windows.resolve_cmd_exe_failed", + "无法定位系统 cmd.exe 路径".to_string(), + "Could not locate system cmd.exe path.".to_string(), + )); + } + if len as usize >= buffer.len() { + buffer.resize(len as usize + 1, 0); + let len2 = unsafe { GetSystemDirectoryW(buffer.as_mut_ptr(), buffer.len() as u32) }; + if len2 == 0 || len2 as usize >= buffer.len() { + return Err(AppError::localized( + "windows.resolve_cmd_exe_failed", + "无法定位系统 cmd.exe 路径".to_string(), + "Could not locate system cmd.exe path.".to_string(), + )); + } + } + let system_dir = PathBuf::from(std::ffi::OsString::from_wide(&buffer[..len as usize])); + Ok(system_dir.join("cmd.exe")) } // ── command line construction ──────────────────────────────────────── From c35a07191aa21b609188552d4ac3e92cee626608 Mon Sep 17 00:00:00 2001 From: aloneatwar <2318583515@qq.com> Date: Mon, 27 Apr 2026 08:20:25 +0800 Subject: [PATCH 14/16] fix(windows): preserve per-drive current-directory vars in custom env block CreateProcessW docs require callers to explicitly preserve =X: per-drive current-directory entries when supplying a custom env block. Update build_env_block_with_override to separate drive vars from regular vars, keep drive vars in original order, sort regular vars alphabetically, and place drive vars first in the output block. Add automated test verifying sorting, override replacement, and double-null termination. Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/cli/windows_temp_launch.rs | 97 ++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/cli/windows_temp_launch.rs b/src-tauri/src/cli/windows_temp_launch.rs index da0f9888..13b612d2 100644 --- a/src-tauri/src/cli/windows_temp_launch.rs +++ b/src-tauri/src/cli/windows_temp_launch.rs @@ -255,26 +255,56 @@ pub(crate) fn build_windows_command_line(program: &OsStr, args: &[OsString]) -> pub(crate) fn build_env_block_with_override(key: &str, value: &OsStr) -> Vec { use std::os::windows::ffi::OsStrExt; - let mut vars: Vec<(std::ffi::OsString, std::ffi::OsString)> = std::env::vars_os() - .filter(|(k, _)| !k.to_string_lossy().eq_ignore_ascii_case(key)) - .collect(); + // When lpEnvironment is non-null, CreateProcessW does not automatically + // propagate the hidden "=X:" per-drive current-directory entries. + // Microsoft docs say callers must preserve them explicitly. + // See: https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw + let mut drive_vars: Vec<(std::ffi::OsString, std::ffi::OsString)> = Vec::new(); + let mut regular_vars: Vec<(std::ffi::OsString, std::ffi::OsString)> = Vec::new(); + + for (k, v) in std::env::vars_os() { + let k_str = k.to_string_lossy(); + if k_str.starts_with('=') + && k_str.len() >= 2 + && k_str.as_bytes()[1].is_ascii_alphabetic() + { + // Per-drive current-directory variable (e.g., "=C:", "=D:") + drive_vars.push((k, v)); + } else if !k_str.eq_ignore_ascii_case(key) { + regular_vars.push((k, v)); + } + } + // Add our override - vars.push((std::ffi::OsString::from(key), value.to_os_string())); - // Windows docs say caller-supplied environment blocks should be sorted. - // Sort by key case-insensitively to match Windows conventions. - vars.sort_by(|(a, _), (b, _)| { + regular_vars.push((std::ffi::OsString::from(key), value.to_os_string())); + + // Sort regular variables alphabetically (case-insensitively). + // Drive vars are kept in their original order (they are not required to + // be sorted, and typically appear first in GetEnvironmentStringsW). + regular_vars.sort_by(|(a, _), (b, _)| { a.to_string_lossy() .to_lowercase() .cmp(&b.to_string_lossy().to_lowercase()) }); let mut result = Vec::new(); - for (k, v) in vars { + + // Write drive vars first (must be present in a custom block) + for (k, v) in drive_vars { result.extend(k.encode_wide()); result.push(b'=' as u16); result.extend(v.encode_wide()); result.push(0); } + + // Write sorted regular vars + for (k, v) in regular_vars { + result.extend(k.encode_wide()); + result.push(b'=' as u16); + result.extend(v.encode_wide()); + result.push(0); + } + // Double-null terminate the block result.push(0); result @@ -958,6 +988,57 @@ mod tests { // process exit will reclaim the memory. } + /// Verify that `build_env_block_with_override` produces a sorted, + /// double-null-terminated block containing the override and preserving + /// existing variables (including per-drive current-directory entries). + #[cfg(windows)] + #[test] + fn build_env_block_with_override_sorted_and_terminated() { + let block = build_env_block_with_override("CC_SWITCH_TEST_VAR", OsStr::new("override_value")); + // Convert back to strings for inspection + let mut entries = Vec::new(); + let mut start = 0usize; + for (i, &ch) in block.iter().enumerate() { + if ch == 0 { + if i == start { + // Double-null terminator + break; + } + let s = String::from_utf16_lossy(&block[start..i]); + entries.push(s); + start = i + 1; + } + } + // Must end with a double-null (we broke at the second null) + assert!( + block.len() >= 2 && block[block.len() - 1] == 0 && block[block.len() - 2] == 0, + "block must be double-null terminated" + ); + // Must contain our override + assert!( + entries.iter().any(|e| e == "CC_SWITCH_TEST_VAR=override_value"), + "override must be present in block" + ); + // Must not contain duplicate keys (the original CC_SWITCH_TEST_VAR if it existed) + let count = entries + .iter() + .filter(|e| e.starts_with("CC_SWITCH_TEST_VAR=")) + .count(); + assert_eq!(count, 1, "override must replace any pre-existing key"); + // Regular vars (excluding drive vars that start with '=') must be sorted + let regular: Vec<&String> = entries.iter().filter(|e| !e.starts_with('=')).collect(); + let mut sorted = regular.clone(); + sorted.sort_by(|a, b| { + let a_key = a.split('=').next().unwrap_or("").to_lowercase(); + let b_key = b.split('=').next().unwrap_or("").to_lowercase(); + a_key.cmp(&b_key) + }); + assert_eq!( + regular, sorted, + "regular environment variables must be sorted case-insensitively" + ); + } + /// Smoke test: spawn a real child process via the shared Windows path, /// assign it to a Job Object, resume it, and verify the exit code. #[cfg(windows)] From ce0f8c67e2539e733efa87fb9d23cd6666dd4e2f Mon Sep 17 00:00:00 2001 From: aloneatwar <2318583515@qq.com> Date: Mon, 27 Apr 2026 08:27:49 +0800 Subject: [PATCH 15/16] fix(windows): distinguish nested-job fallback from unexpected Job errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AssignProcessToJobObject can fail with ERROR_ACCESS_DENIED when the parent is already inside a job that prohibits nesting. This is an expected graceful degradation, but the previous code used log::warn! which is invisible at the default error log level. - Check the raw OS error code: ACCESS_DENIED → visible eprintln! warning so users know KILL_ON_JOB_CLOSE was lost. - Any other error code → unexpected failure: terminate the child, clean up handles, and return a hard AppError. Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/cli/windows_temp_launch.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/cli/windows_temp_launch.rs b/src-tauri/src/cli/windows_temp_launch.rs index 13b612d2..7aeb4598 100644 --- a/src-tauri/src/cli/windows_temp_launch.rs +++ b/src-tauri/src/cli/windows_temp_launch.rs @@ -518,7 +518,28 @@ pub(crate) fn run_suspended_child( }; if let Err(e) = job.try_assign(h_process) { - log::warn!(target: "windows.job_assign_failed_fallback", "{}", e); + let code = e.raw_os_error().unwrap_or(0); + if code == windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED as i32 { + // Expected nested-job fallback: the parent is already in a job + // that does not allow nested assignments. We degrade gracefully + // but warn the user visibly since KILL_ON_JOB_CLOSE is lost. + eprintln!( + "cc-switch warning: cannot assign child to Job Object (already in a nested job). \ + Child cleanup will rely on orphan scan instead of automatic parent-death termination." + ); + } else { + // Unexpected assignment failure: clean up and fail hard. + unsafe { + let _ = windows_sys::Win32::System::Threading::TerminateProcess(h_process, 1); + CloseHandle(h_thread); + CloseHandle(h_process); + } + return Err(AppError::localized( + "windows.job_assign_failed", + format!("无法将子进程分配到 Job Object: {e}"), + format!("Failed to assign child process to Job Object: {e}"), + )); + } } let resume_result = unsafe { ResumeThread(h_thread) }; From 5c650e42b2f17968036d4a3a4c933975b0761f13 Mon Sep 17 00:00:00 2001 From: aloneatwar <2318583515@qq.com> Date: Mon, 27 Apr 2026 20:55:02 +0800 Subject: [PATCH 16/16] fix(orphan-scan): track child process via sidecar and detect PID reuse - Write .child-meta sidecar with actual child PID and creation time so orphan_scan judges by child alive state instead of launcher PID. This prevents nested-job fallback from deleting a still-running CODEX_HOME when the launcher dies first. [windows_temp_launch.rs] - Add Linux /proc/{pid}/stat starttime validation to detect PID reuse in orphan_scan Unix branch. [orphan_scan.rs] - Fix windows-start-qa.ps1 M2 to recursively detect descendants (e.g. node.exe from npm .cmd shims) via CIM instead of Get-Process -Name. - Also reap orphaned .child-meta.tmp crash residuals. Co-Authored-By: Claude Opus 4.7 --- scripts/windows-start-qa.ps1 | 43 ++- src-tauri/src/cli/claude_temp_launch.rs | 13 +- src-tauri/src/cli/codex_temp_launch.rs | 6 + src-tauri/src/cli/orphan_scan.rs | 387 ++++++++++++++++++++++- src-tauri/src/cli/windows_temp_launch.rs | 101 +++++- 5 files changed, 532 insertions(+), 18 deletions(-) diff --git a/scripts/windows-start-qa.ps1 b/scripts/windows-start-qa.ps1 index 9bbc7a5c..1ed9e0f6 100644 --- a/scripts/windows-start-qa.ps1 +++ b/scripts/windows-start-qa.ps1 @@ -133,6 +133,32 @@ function Get-ExePath { return $null } +function Get-DescendantPids { + <# + .SYNOPSIS + Recursively collect all descendant PIDs of a root PID using CIM. + Required because npm .cmd shims spawn node.exe (not "claude"), + so Get-Process -Name "claude" misses the real child process. + #> + param([Parameter(Mandatory)] [int]$RootPid) + $all = Get-CimInstance -ClassName Win32_Process -ErrorAction SilentlyContinue | + Select-Object -Property ProcessId, ParentProcessId + $descendants = [System.Collections.Generic.HashSet[int]]::new() + $queue = [System.Collections.Generic.Queue[int]]::new() + [void]$queue.Enqueue($RootPid) + while ($queue.Count -gt 0) { + $current = $queue.Dequeue() + foreach ($proc in $all) { + if ($proc.ParentProcessId -eq $current -and $proc.ProcessId -ne $current) { + if ($descendants.Add($proc.ProcessId)) { + [void]$queue.Enqueue($proc.ProcessId) + } + } + } + } + return $descendants +} + function Build-StubExe { <# .SYNOPSIS @@ -338,15 +364,22 @@ if ($LASTEXITCODE -ne 0) { } else { Write-Host " Temp entry appeared: $tempEntry" + # Snapshot descendants BEFORE killing parent (npm .cmd shim → node.exe) + $descendants = Get-DescendantPids -RootPid $proc.Id + # Kill the parent taskkill /F /PID $proc.Id 2>$null | Out-Null Start-Sleep -Seconds 1 - # Check if child claude processes are still alive - $claudeProcs = Get-Process -Name "claude" -ErrorAction SilentlyContinue - if ($claudeProcs) { - Record-Fail "Claude child process(es) still alive after parent taskkill" - $claudeProcs | Stop-Process -Force -ErrorAction SilentlyContinue + # Verify every captured descendant is dead (catches node.exe etc.) + $alive = $descendants | Where-Object { + $null -ne (Get-Process -Id $_ -ErrorAction SilentlyContinue) + } + if ($alive) { + Record-Fail "Child process(es) still alive after parent taskkill: $($alive -join ', ')" + $alive | ForEach-Object { + Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue + } } else { Record-Pass "Claude child process terminated along with parent (Job Object)" } diff --git a/src-tauri/src/cli/claude_temp_launch.rs b/src-tauri/src/cli/claude_temp_launch.rs index 29e439be..777a6519 100644 --- a/src-tauri/src/cli/claude_temp_launch.rs +++ b/src-tauri/src/cli/claude_temp_launch.rs @@ -149,7 +149,13 @@ pub(crate) fn exec_prepared_claude( let (program, args, application_name) = build_claude_command_windows(prepared, native_args)?; - let exit_code = run_suspended_child(&program, &args, None, application_name.as_deref())?; + let exit_code = run_suspended_child( + &program, + &args, + None, + application_name.as_deref(), + Some(&prepared.settings_path), + )?; if exit_code != 0 { return Err(AppError::localized( @@ -348,6 +354,11 @@ fn create_secret_temp_file(path: &Path) -> Result { } fn cleanup_temp_settings_file(path: &Path) -> Result<(), AppError> { + // Best-effort: remove the orphan-scan sidecar regardless of how the + // settings file removal goes. The sidecar lives next to the temp file, + // so a stranded sidecar without its main entry would otherwise wait for + // the periodic orphan-sidecar reap. + crate::cli::orphan_scan::remove_sidecar_for(path); match fs::remove_file(path) { Ok(()) => Ok(()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), diff --git a/src-tauri/src/cli/codex_temp_launch.rs b/src-tauri/src/cli/codex_temp_launch.rs index dbadc47d..532aed16 100644 --- a/src-tauri/src/cli/codex_temp_launch.rs +++ b/src-tauri/src/cli/codex_temp_launch.rs @@ -140,6 +140,7 @@ pub(crate) fn exec_prepared_codex( &args, Some(&env_block), application_name.as_deref(), + Some(&prepared.codex_home), )?; if exit_code != 0 { @@ -375,6 +376,11 @@ fn create_secret_temp_file(path: &Path) -> Result { } fn cleanup_temp_codex_home(path: &Path) -> Result<(), AppError> { + // Best-effort: remove the orphan-scan sidecar regardless of how the + // directory removal goes. The sidecar lives next to the temp dir, so a + // stranded sidecar without its main entry would otherwise wait for the + // periodic orphan-sidecar reap. + crate::cli::orphan_scan::remove_sidecar_for(path); match fs::remove_dir_all(path) { Ok(()) => Ok(()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), diff --git a/src-tauri/src/cli/orphan_scan.rs b/src-tauri/src/cli/orphan_scan.rs index a6851b9e..2ebabe95 100644 --- a/src-tauri/src/cli/orphan_scan.rs +++ b/src-tauri/src/cli/orphan_scan.rs @@ -1,6 +1,15 @@ +use std::ffi::OsString; use std::fs; use std::path::{Path, PathBuf}; +/// Suffix for the sidecar metadata file that records the actual *child* +/// process PID and creation time. The sidecar is written by the launcher +/// after the child has been spawned (Windows only), and lets the orphan +/// scanner judge liveness by the child instead of the launcher. This is +/// what keeps a still-running Codex/Claude session safe when the launcher +/// dies in the nested-job fallback path. +pub(crate) const SIDECAR_SUFFIX: &str = ".child-meta"; + /// Information extracted from a temp file/directory name. struct TempEntryInfo { path: PathBuf, @@ -8,6 +17,53 @@ struct TempEntryInfo { nanos: u128, } +/// Compute the sidecar path for a given temp entry path by appending the +/// `SIDECAR_SUFFIX`. Works for both files (claude .json) and directories +/// (codex CODEX_HOME) — the sidecar always lives next to the entry. +pub(crate) fn sidecar_path_for(temp_path: &Path) -> PathBuf { + let mut s: OsString = temp_path.as_os_str().to_owned(); + s.push(SIDECAR_SUFFIX); + PathBuf::from(s) +} + +/// Write the sidecar metadata file with the child's PID and creation +/// time in nanos. Uses an atomic create-then-rename so a crash mid-write +/// cannot leave a partial sidecar that the scanner would treat as authoritative. +pub(crate) fn write_child_sidecar( + temp_path: &Path, + child_pid: u32, + creation_nanos: u128, +) -> std::io::Result<()> { + let sidecar = sidecar_path_for(temp_path); + let tmp = { + let mut s: OsString = sidecar.as_os_str().to_owned(); + s.push(".tmp"); + PathBuf::from(s) + }; + let content = format!("{child_pid}:{creation_nanos}"); + // Best-effort: remove any leftover .tmp from a previous failed attempt. + let _ = fs::remove_file(&tmp); + fs::write(&tmp, content.as_bytes())?; + fs::rename(&tmp, &sidecar) +} + +/// Best-effort removal of the sidecar associated with `temp_path`. Errors +/// are intentionally swallowed: a stray sidecar will eventually be reaped +/// by the orphan-sidecar pass on the next scan. +pub(crate) fn remove_sidecar_for(temp_path: &Path) { + let sidecar = sidecar_path_for(temp_path); + let _ = fs::remove_file(sidecar); +} + +fn parse_sidecar(sidecar: &Path) -> Option<(u32, u128)> { + let content = fs::read_to_string(sidecar).ok()?; + let trimmed = content.trim(); + let mut parts = trimmed.splitn(2, ':'); + let pid = parts.next()?.parse::().ok()?; + let nanos = parts.next()?.parse::().ok()?; + Some((pid, nanos)) +} + /// Scan the temp directory for orphaned cc-switch temp files/directories /// and clean them up. Returns the number of entries removed. /// @@ -33,9 +89,46 @@ pub fn scan_and_clean(temp_dir: &Path) -> usize { } } + // Reap sidecars whose main entry is gone (e.g., a previous run already + // removed the entry but failed to remove the sidecar). This is what + // bounds long-term sidecar accumulation. + cleanup_orphan_sidecars(temp_dir); + cleaned } +fn cleanup_orphan_sidecars(temp_dir: &Path) { + let dir = match fs::read_dir(temp_dir) { + Ok(d) => d, + Err(_) => return, + }; + for entry in dir.flatten() { + let name = entry.file_name(); + let name_str = match name.to_str() { + Some(s) => s, + None => continue, + }; + let stem = if let Some(s) = name_str.strip_suffix(SIDECAR_SUFFIX) { + Some(s) + } else if let Some(s) = name_str.strip_suffix(".child-meta.tmp") { + Some(s) + } else { + None + }; + if let Some(stem) = stem { + // Only consider sidecars that belong to a cc-switch entry; ignore + // any unrelated `.child-meta` file a user might have left behind. + if !(stem.starts_with("cc-switch-claude-") || stem.starts_with("cc-switch-codex-")) { + continue; + } + let main_path = temp_dir.join(stem); + if !main_path.exists() { + let _ = fs::remove_file(entry.path()); + } + } + } +} + fn collect_cc_switch_entries(temp_dir: &Path) -> Result, std::io::Error> { let mut entries = Vec::new(); let dir = fs::read_dir(temp_dir)?; @@ -48,6 +141,12 @@ fn collect_cc_switch_entries(temp_dir: &Path) -> Result, std: None => continue, }; + // Sidecars are reaped separately so the scanner only inspects + // primary temp entries here. + if name_str.ends_with(SIDECAR_SUFFIX) { + continue; + } + if let Some(info) = parse_cc_switch_name(name_str, entry.path()) { entries.push(info); } @@ -73,8 +172,15 @@ fn parse_cc_switch_name(name: &str, path: PathBuf) -> Option { } fn should_clean(entry: &TempEntryInfo) -> bool { - // Living PID = leave alone. Dead PID = clean immediately: the file's - // owner process is gone, so the file is a true orphan regardless of age. + // Sidecar takes precedence: it records the *actual* child process. This + // is what makes the nested-job fallback safe — the launcher's PID being + // dead no longer implies the user-visible Codex/Claude session is dead. + let sidecar = sidecar_path_for(&entry.path); + if let Some((child_pid, child_nanos)) = parse_sidecar(&sidecar) { + return !is_pid_alive(child_pid, child_nanos); + } + // Legacy / pre-spawn entries: fall back to the launcher PID stored in + // the filename. Same semantics as before this fix landed. !is_pid_alive(entry.pid, entry.nanos) } @@ -203,7 +309,74 @@ fn is_pid_alive(pid: u32, file_nanos: u128) -> bool { } } -#[cfg(unix)] +#[cfg(target_os = "linux")] +fn read_pid_start_time_nanos(pid: u32) -> Option { + // Boot time (Unix epoch seconds). Stays constant for the life of the + // kernel, so reading it on every call is cheap. + let stat = std::fs::read_to_string("/proc/stat").ok()?; + let btime_secs: u64 = stat + .lines() + .find_map(|line| line.strip_prefix("btime ").and_then(|s| s.trim().parse().ok()))?; + + // Process stat. The `comm` field is wrapped in parens and may itself + // contain spaces, parens, or commas, so we anchor on the LAST `)` + // before splitting the rest of the line by whitespace. + let proc_stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + let close_paren = proc_stat.rfind(')')?; + let after_comm = &proc_stat[close_paren + 1..]; + // Fields after `comm`: state ppid pgrp session tty_nr tpgid flags + // minflt cminflt majflt cmajflt utime stime cutime cstime priority + // nice num_threads itrealvalue starttime ... + // starttime is the 20th token (0-indexed: 19). + let starttime_ticks: u64 = after_comm + .split_whitespace() + .nth(19) + .and_then(|s| s.parse().ok())?; + + let clk_tck = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; + if clk_tck <= 0 { + return None; + } + let clk_tck = clk_tck as u128; + // ticks → nanos: starttime / clk_tck * 1e9, computed without losing + // precision for large starttimes. + let start_nanos_since_boot = (starttime_ticks as u128).saturating_mul(1_000_000_000u128) / clk_tck; + let btime_nanos = (btime_secs as u128).saturating_mul(1_000_000_000u128); + Some(btime_nanos.saturating_add(start_nanos_since_boot)) +} + +#[cfg(target_os = "linux")] +fn is_pid_alive(pid: u32, file_nanos: u128) -> bool { + // Liveness probe: if kill(pid, 0) reports ESRCH the PID is unused. + // EPERM means the PID exists but belongs to another user — treat as + // alive and let the start-time check below decide on PID reuse. + let kill_result = unsafe { libc::kill(pid as i32, 0) }; + if kill_result != 0 { + let err = std::io::Error::last_os_error().raw_os_error().unwrap_or(0); + if err != libc::EPERM as i32 { + return false; + } + } + + // Start-time probe via /proc. The launcher always created the temp + // file *after* it started, so a process whose start time is later than + // file_nanos (with a small tolerance for clock-tick precision) must be + // a different process that has reused the PID. + if let Some(start_nanos) = read_pid_start_time_nanos(pid) { + // 2 s tolerance covers the worst-case CLK_TCK quantum (10 ms) plus + // any clock skew between SystemTime::now() and the proc clock. + const TOLERANCE_NANOS: u128 = 2_000_000_000; + if start_nanos > file_nanos.saturating_add(TOLERANCE_NANOS) { + return false; + } + } + // /proc unreadable or start time consistent: prefer false-positive + // "still alive" over false-positive "dead", since the latter would + // delete user state. + true +} + +#[cfg(all(unix, not(target_os = "linux")))] fn is_pid_alive(pid: u32, _file_nanos: u128) -> bool { unsafe { let result = libc::kill(pid as i32, 0); @@ -356,13 +529,13 @@ mod tests { assert!(!should_clean(&entry)); } - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "linux")))] #[test] fn alive_pid_old_file_no_clean() { - // On Windows, is_pid_alive also validates creation time, so an old - // nanos with the current PID would be treated as PID reuse and - // correctly considered dead. This test is Unix-only because kill(0) - // does not check creation time. + // On non-Linux Unix (macOS, BSD), is_pid_alive uses kill(pid, 0) + // only and does not validate process start time, so an old nanos + // with a live PID is *not* treated as PID reuse. /proc-based + // start-time validation is Linux-only. let old_nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -376,6 +549,85 @@ mod tests { assert!(!should_clean(&entry)); } + #[cfg(target_os = "linux")] + #[test] + fn linux_pid_reuse_detected_by_start_time() { + // Linux is_pid_alive validates start time via /proc//stat, + // so a live PID with a clearly-older file_nanos (test process + // started long after the file was supposedly created) must be + // treated as PID reuse. Mirrors the Windows test. + let old_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .saturating_sub(25 * 60 * 60 * 1_000_000_000u128); + let entry = TempEntryInfo { + path: PathBuf::from("/tmp/cc-switch-claude-demo-1-0.json"), + pid: std::process::id(), + nanos: old_nanos, + }; + // Test process start_time >> old_nanos, so PID reuse must clean + assert!(should_clean(&entry)); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_alive_pid_with_recent_file_no_clean() { + // Conversely: a file_nanos *after* the live process started must + // NOT be considered PID reuse. The launcher always wrote the file + // after the process started, so this is the normal "still alive" + // path. + let recent_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let entry = TempEntryInfo { + path: PathBuf::from("/tmp/cc-switch-claude-demo-1-0.json"), + pid: std::process::id(), + nanos: recent_nanos, + }; + assert!(!should_clean(&entry)); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_dead_pid_returns_false_immediately() { + // PID 1 is init, always alive. PID 99999 is virtually never alive + // in a CI container or a developer machine. Probing it must return + // false without hitting /proc. + assert!(!is_pid_alive(99999, 0)); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_read_pid_start_time_handles_comm_with_spaces_and_parens() { + // The /proc//stat parser must anchor on the LAST `)` so a + // comm like "(My (Process))" with embedded parens still works. + // We verify by reading the current process's start time, which is + // the only PID we know exists. + let pid = std::process::id(); + let nanos = read_pid_start_time_nanos(pid); + assert!( + nanos.is_some(), + "must read start time for current process, got None" + ); + let nanos = nanos.unwrap(); + let now_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + assert!( + nanos <= now_nanos, + "start_time {nanos} must be <= now {now_nanos}" + ); + // Sanity: start time should be within the last day for a test run + let one_day_ago = now_nanos.saturating_sub(24 * 60 * 60 * 1_000_000_000u128); + assert!( + nanos >= one_day_ago, + "start_time {nanos} should be within the last 24h of {now_nanos}" + ); + } + #[cfg(windows)] #[test] fn windows_pid_reuse_detected_by_creation_time() { @@ -447,4 +699,123 @@ mod tests { assert_eq!(cleaned, 1); assert!(!orphan.exists()); } + + #[test] + fn sidecar_dead_child_triggers_clean_even_when_launcher_alive() { + // Sidecar precedence: a sidecar pointing at a dead child PID must + // beat a still-alive launcher PID in the filename. This is the core + // invariant for the nested-job fallback path — the launcher can be + // alive while the user-visible Codex/Claude session has died. + let temp = TempDir::new().expect("create temp dir"); + let recent_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let entry_path = temp + .path() + .join(format!("cc-switch-claude-demo-{}-{recent_nanos}.json", std::process::id())); + std::fs::write(&entry_path, "{}").expect("write entry"); + + // Sidecar references a guaranteed-dead PID with arbitrary nanos. + write_child_sidecar(&entry_path, 99999, 0).expect("write sidecar"); + + let entry = TempEntryInfo { + path: entry_path.clone(), + // Launcher PID = current process (still alive) and recent nanos + pid: std::process::id(), + nanos: recent_nanos, + }; + assert!( + should_clean(&entry), + "sidecar's dead child PID must override the alive launcher PID" + ); + } + + #[cfg(unix)] + #[test] + fn sidecar_alive_child_blocks_clean_even_when_launcher_filename_old() { + // Inverse of the test above: a sidecar pointing at a still-alive + // child PID must keep the entry around even if the launcher PID's + // filename nanos look stale. This protects the running Codex + // session in the nested-job fallback path. Unix-only because on + // Windows is_pid_alive cross-validates creation time, which the + // synthetic file_nanos in this test would not match. + let temp = TempDir::new().expect("create temp dir"); + let old_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .saturating_sub(25 * 60 * 60 * 1_000_000_000u128); + let entry_path = temp + .path() + .join(format!("cc-switch-codex-demo-99999-{old_nanos}")); + std::fs::create_dir(&entry_path).expect("create entry"); + + // Sidecar points at this very test process, which is obviously + // alive. The Unix is_pid_alive ignores nanos, so any nanos works. + write_child_sidecar(&entry_path, std::process::id(), 0).expect("write sidecar"); + + let entry = TempEntryInfo { + path: entry_path.clone(), + // Launcher PID would otherwise look dead + pid: 99999, + nanos: old_nanos, + }; + assert!( + !should_clean(&entry), + "sidecar's alive child PID must keep the entry even if launcher PID is stale" + ); + } + + #[test] + fn sidecar_orphan_is_reaped_when_main_entry_missing() { + // A sidecar without its main entry must be cleaned by the + // orphan-sidecar reap pass; otherwise sidecars accumulate forever + // when the launcher crashes mid-cleanup. + let temp = TempDir::new().expect("create temp dir"); + let entry_path = temp.path().join("cc-switch-claude-demo-99999-0.json"); + write_child_sidecar(&entry_path, 99999, 0).expect("write sidecar"); + // Note: we never create the main entry. + let sidecar = sidecar_path_for(&entry_path); + assert!(sidecar.exists(), "sidecar must exist before reap"); + + let _ = scan_and_clean(temp.path()); + assert!(!sidecar.exists(), "orphan sidecar must be reaped"); + } + + #[test] + fn sidecar_unrelated_child_meta_files_are_preserved() { + // Reaper must only touch sidecars that belong to cc-switch entries, + // not arbitrary `.child-meta` files a user might leave behind. + let temp = TempDir::new().expect("create temp dir"); + let unrelated = temp.path().join("not-cc-switch.child-meta"); + std::fs::write(&unrelated, "12345:0").expect("write unrelated sidecar"); + + let _ = scan_and_clean(temp.path()); + assert!( + unrelated.exists(), + "unrelated .child-meta files must not be touched" + ); + } + + #[test] + fn sidecar_atomic_write_replaces_existing() { + // Sequential writes must atomically replace the previous content; + // the rename-over-existing must not leave stale .tmp files. + let temp = TempDir::new().expect("create temp dir"); + let entry_path = temp.path().join("cc-switch-claude-demo-1-0.json"); + + write_child_sidecar(&entry_path, 1234, 100).expect("first write"); + write_child_sidecar(&entry_path, 5678, 200).expect("second write"); + + let sidecar = sidecar_path_for(&entry_path); + let content = std::fs::read_to_string(&sidecar).expect("read sidecar"); + assert_eq!(content, "5678:200", "second write must replace first"); + + // No .tmp leftover + let mut s: OsString = sidecar.as_os_str().to_owned(); + s.push(".tmp"); + let tmp_path = PathBuf::from(s); + assert!(!tmp_path.exists(), "no stale .tmp must remain"); + } } diff --git a/src-tauri/src/cli/windows_temp_launch.rs b/src-tauri/src/cli/windows_temp_launch.rs index 7aeb4598..6dab95ef 100644 --- a/src-tauri/src/cli/windows_temp_launch.rs +++ b/src-tauri/src/cli/windows_temp_launch.rs @@ -412,7 +412,7 @@ pub(crate) fn spawn_suspended_createprocessw( args: &[OsString], env_block: Option<&[u16]>, application_name: Option<&std::path::Path>, -) -> Result<(HANDLE, HANDLE), AppError> { +) -> Result<(HANDLE, HANDLE, u32), AppError> { use std::ptr; let application_name_wide: Option> = application_name.map(|p| { @@ -458,7 +458,56 @@ pub(crate) fn spawn_suspended_createprocessw( return Err(AppError::windows_create_process_failed(code)); } - Ok((process_info.hProcess, process_info.hThread)) + Ok(( + process_info.hProcess, + process_info.hThread, + process_info.dwProcessId, + )) +} + +/// Read the creation time of the process referenced by `handle` and convert +/// it to nanos-since-Unix-epoch. Used to record the child PID + creation +/// time pair in the orphan-scan sidecar so the next scan can detect PID +/// reuse independently of the launcher's own lifetime. +#[cfg(windows)] +unsafe fn process_creation_time_nanos(handle: HANDLE) -> Option { + use windows_sys::Win32::Foundation::FILETIME; + use windows_sys::Win32::System::Threading::GetProcessTimes; + + let mut creation_time = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let mut exit_time = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let mut kernel_time = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let mut user_time = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + + let ok = GetProcessTimes( + handle, + &mut creation_time, + &mut exit_time, + &mut kernel_time, + &mut user_time, + ); + + if ok == 0 { + return None; + } + + let low = creation_time.dwLowDateTime as u64; + let high = creation_time.dwHighDateTime as u64; + let intervals = (high << 32) | low; + let nanos_since_1601 = intervals as u128 * 100; + Some(nanos_since_1601.saturating_sub(11644473600_000_000_000u128)) } #[cfg(windows)] @@ -492,19 +541,61 @@ pub(crate) fn wait_for_child(process_handle: HANDLE) -> Result { /// Object with KILL_ON_JOB_CLOSE, assign the process, resume the main /// thread, wait for termination, and return the exit code. All handles are /// cleaned up regardless of success or failure. +/// +/// `temp_path`, when supplied, is the path to the temp settings file (claude) +/// or temp CODEX_HOME directory (codex) that the child will be reading from. +/// After the suspended child is created we record its PID + creation time +/// next to that path as an `.child-meta` sidecar. The orphan scanner then +/// uses the *child* liveness (via the sidecar) instead of the launcher PID +/// to decide whether the temp entry is safe to delete. This is what makes +/// the nested-job fallback path safe: even if the launcher dies but the +/// child Codex/Claude session keeps running, the temp dir is *not* deleted. #[cfg(windows)] pub(crate) fn run_suspended_child( program: &std::path::Path, args: &[OsString], env_block: Option<&[u16]>, application_name: Option<&std::path::Path>, + temp_path: Option<&std::path::Path>, ) -> Result { use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::System::Threading::ResumeThread; - let (h_process, h_thread) = + let (h_process, h_thread, child_pid) = spawn_suspended_createprocessw(program, args, env_block, application_name)?; + // Write the sidecar before resuming the main thread, so even if + // ResumeThread or the wait loop crashes the launcher between here and + // the cleanup call, a future orphan scan trusts the *child* PID rather + // than the (now-dead) launcher PID. Failure to write the sidecar is + // non-fatal: we just lose the precise child-PID liveness check and + // fall back to the legacy launcher-PID heuristic on the next scan. + if let Some(path) = temp_path { + let creation_nanos = unsafe { process_creation_time_nanos(h_process) }; + match creation_nanos { + Some(nanos) => { + if let Err(e) = + crate::cli::orphan_scan::write_child_sidecar(path, child_pid, nanos) + { + log::warn!( + target: "windows_temp_launch", + "Failed to write child sidecar for {}: {}. Orphan scan will fall back to launcher PID.", + path.display(), + e + ); + } + } + None => { + log::warn!( + target: "windows_temp_launch", + "GetProcessTimes failed for child PID {}; orphan scan will fall back to launcher PID for {}.", + child_pid, + path.display() + ); + } + } + } + let job = match Job::create_with_kill_on_close() { Ok(job) => job, Err(e) => { @@ -523,6 +614,8 @@ pub(crate) fn run_suspended_child( // Expected nested-job fallback: the parent is already in a job // that does not allow nested assignments. We degrade gracefully // but warn the user visibly since KILL_ON_JOB_CLOSE is lost. + // The sidecar above ensures orphan_scan still detects when the + // *child* exits, so a stuck temp dir does not leak indefinitely. eprintln!( "cc-switch warning: cannot assign child to Job Object (already in a nested job). \ Child cleanup will rely on orphan scan instead of automatic parent-death termination." @@ -1066,7 +1159,7 @@ mod tests { #[test] fn windows_smoke_test_spawn_job_wait_exit_code() { use windows_sys::Win32::System::Threading::ResumeThread; - let (h_process, h_thread) = spawn_suspended_createprocessw( + let (h_process, h_thread, _pid) = spawn_suspended_createprocessw( std::path::Path::new("cmd.exe"), &[ OsString::from("/c"),