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..1ed9e0f6 --- /dev/null +++ b/scripts/windows-start-qa.ps1 @@ -0,0 +1,593 @@ +#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 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 + 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" + + # 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 + + # 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)" + } + + # 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/.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.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..2a624aca 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_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/claude_temp_launch.rs b/src-tauri/src/cli/claude_temp_launch.rs index 6085afcc..777a6519 100644 --- a/src-tauri/src/cli/claude_temp_launch.rs +++ b/src-tauri/src/cli/claude_temp_launch.rs @@ -1,7 +1,11 @@ use std::ffi::OsString; -use std::fs::{self, File, OpenOptions}; +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}; +#[cfg(not(windows))] use std::time::{SystemTime, UNIX_EPOCH}; use crate::error::AppError; @@ -96,14 +100,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 +136,124 @@ pub(crate) fn exec_prepared_claude( )) } -#[cfg(not(unix))] +#[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 crate::cli::windows_temp_launch::{ + run_suspended_child, ScopedConsoleCtrlHandler, + }; + + let _ctrl_guard = ScopedConsoleCtrlHandler::install()?; + + 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(), + Some(&prepared.settings_path), + )?; + + if exit_code != 0 { + return Err(AppError::localized( + "claude.temp_launch_exit_nonzero", + format!("Claude 进程退出码非零: {exit_code}"), + format!("Claude process exited with non-zero code: {exit_code}"), + )); + } + + Ok(()) +} + +#[cfg(windows)] +fn build_claude_command_windows( + prepared: &PreparedClaudeLaunch, + native_args: &[OsString], +) -> Result<(PathBuf, Vec, Option), AppError> { + 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 { + 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![ + OsString::from("/c"), + OsString::from(exe_str.as_ref()), + OsString::from("--settings"), + OsString::from(&prepared.settings_path), + ]; + args.extend_from_slice(native_args); + // 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"), + OsString::from(&prepared.settings_path), + ]; + args.extend_from_slice(native_args); + Ok((prepared.executable.clone(), args, Some(prepared.executable.clone()))) + } +} + +#[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 + ), + ), + 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 + ), + ), + } } fn write_temp_settings_file( @@ -167,12 +273,17 @@ 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() .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() ); @@ -217,7 +328,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)] + { + crate::cli::windows_temp_launch::restrict_to_owner(path, false)?; + } Ok(()) } @@ -235,14 +350,15 @@ fn create_secret_temp_file(path: &Path) -> Result { #[cfg(not(unix))] fn create_secret_temp_file(path: &Path) -> Result { - OpenOptions::new() - .write(true) - .create_new(true) - .open(path) - .map_err(|err| AppError::io(path, err)) + crate::cli::windows_temp_launch::create_secret_temp_file(path) } 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(()), @@ -430,6 +546,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 e036d4b8..532aed16 100644 --- a/src-tauri/src/cli/codex_temp_launch.rs +++ b/src-tauri/src/cli/codex_temp_launch.rs @@ -1,7 +1,11 @@ use std::ffi::OsString; -use std::fs::{self, File, OpenOptions}; +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}; +#[cfg(not(windows))] use std::time::{SystemTime, UNIX_EPOCH}; use crate::codex_config::validate_config_toml; @@ -59,7 +63,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 +109,51 @@ pub(crate) fn exec_prepared_codex( )) } -#[cfg(not(unix))] +#[cfg(windows)] +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, run_suspended_child, + ScopedConsoleCtrlHandler, + }; + + 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()); + + // 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 application_name: Option = if is_cmd_shim(&prepared.executable) { + Some(crate::cli::windows_temp_launch::resolve_system_cmd_exe()?) + } else { + Some(program.clone()) + }; + + let exit_code = run_suspended_child( + &program, + &args, + Some(&env_block), + application_name.as_deref(), + Some(&prepared.codex_home), + )?; + + 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 +166,75 @@ pub(crate) fn exec_prepared_codex( )) } +#[cfg(windows)] +fn build_command_windows( + prepared: &PreparedCodexLaunch, + native_args: &[OsString], +) -> Result<(std::path::PathBuf, Vec), AppError> { + use crate::cli::windows_temp_launch::{is_cmd_shim, validate_cmd_arg}; + + if is_cmd_shim(&prepared.executable) { + // 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 { + 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)]; + 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 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 + ), + ), + 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 + ), + ), + } +} + fn write_temp_codex_home(temp_dir: &Path, provider: &Provider) -> Result { write_temp_codex_home_with(temp_dir, provider, finalize_temp_codex_home) } @@ -161,18 +283,26 @@ 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() .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() ); 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)?; @@ -213,7 +343,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)] + { + crate::cli::windows_temp_launch::restrict_to_owner(path, true)?; + } Ok(()) } @@ -238,14 +372,15 @@ fn create_secret_temp_file(path: &Path) -> Result { #[cfg(not(unix))] fn create_secret_temp_file(path: &Path) -> Result { - OpenOptions::new() - .write(true) - .create_new(true) - .open(path) - .map_err(|err| AppError::io(path, err)) + crate::cli::windows_temp_launch::create_secret_temp_file(path) } 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(()), @@ -281,6 +416,79 @@ mod tests { use std::time::Duration; use tempfile::TempDir; + #[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); diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index 12cf8b1e..6889f44c 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -4,10 +4,13 @@ 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; pub mod interactive; +pub mod orphan_scan; pub mod terminal; pub mod tui; pub mod ui; @@ -61,7 +64,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 +187,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 +204,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 +231,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 +249,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 +266,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 +301,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 +317,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..2ebabe95 --- /dev/null +++ b/src-tauri/src/cli/orphan_scan.rs @@ -0,0 +1,821 @@ +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, + pid: u32, + 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. +/// +/// 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; + } + } + } + + // 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)?; + + for entry in dir { + let entry = entry?; + let name = entry.file_name(); + let name_str = match name.to_str() { + Some(s) => s, + 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); + } + } + + 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 { + // 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) +} + +#[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(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); + 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_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()); + } + + #[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(all(unix, not(target_os = "linux")))] + #[test] + fn alive_pid_old_file_no_clean() { + // 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() + .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(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() { + // 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()); + } + + #[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/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/cli/windows_temp_launch.rs b/src-tauri/src/cli/windows_temp_launch.rs new file mode 100644 index 00000000..6dab95ef --- /dev/null +++ b/src-tauri/src/cli/windows_temp_launch.rs @@ -0,0 +1,1192 @@ +use std::ffi::{OsStr, OsString}; +use std::fs::File; +use std::path::{Path, PathBuf}; + +use crate::error::AppError; + +#[cfg(windows)] +use std::os::windows::ffi::{OsStrExt, OsStringExt}; +#[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::SystemInformation::GetSystemDirectoryW; +#[cfg(windows)] +use windows_sys::Win32::System::Threading::{ + CreateProcessW, GetExitCodeProcess, 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)) +} + +/// Result of validating a single argument for `cmd.exe /c` safety. +#[cfg(windows)] +#[derive(Debug, Clone)] +pub(crate) enum CmdArgError { + DoubleQuote(String), + UnsafeTrailingBackslash(String), + Percent(String), + Exclamation(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) + } + 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 +/// 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('%') { + 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())); + } + 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() { + 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 +} + +/// 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 { + 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 ──────────────────────────────────────── + +#[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; + + // 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 + 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(); + + // 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 +} + +// ── 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, u32), 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, + 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)] +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) + } +} + +/// 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. +/// +/// `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, 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) => { + 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) { + 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. + // 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." + ); + } 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) }; + 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)] +fn get_current_user_sid() -> Result, AppError> { + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; + use windows_sys::Win32::Security::{ + GetLengthSid, GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + let mut token: HANDLE = std::ptr::null_mut(); + let result = unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }; + if result == 0 { + return Err(AppError::io("token", std::io::Error::last_os_error())); + } + + 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("token", std::io::Error::last_os_error())); + } + + let token_user = unsafe { &*(buffer.as_ptr() as *const TOKEN_USER) }; + 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 _; + + 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; + + 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("acl", 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, sid_ptr) + }; + if result == 0 { + 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)) + .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(()) +} + +/// 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_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, 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, + 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 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 _, + 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, SetSecurityDescriptorControl, SetSecurityDescriptorDacl, + SECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, SE_DACL_PROTECTED, + }; + 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 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 _, + 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::*; + + #[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 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() { + 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")); + } + + /// 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. + } + + /// 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)] + #[test] + fn windows_smoke_test_spawn_job_wait_exit_code() { + use windows_sys::Win32::System::Threading::ResumeThread; + let (h_process, h_thread, _pid) = 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 af93daf5..ecb8229d 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -83,6 +83,56 @@ 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}"), + ) + } + + /// 错误:创建 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 { 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),