diff --git a/skills/scripts/bootstrap-reverse.ps1 b/skills/scripts/bootstrap-reverse.ps1 index 757eaa1..2f176ce 100644 --- a/skills/scripts/bootstrap-reverse.ps1 +++ b/skills/scripts/bootstrap-reverse.ps1 @@ -632,8 +632,31 @@ function Set-CodexMcpServer { } $lines = @() + $writeUtf8Bom = $false + $newline = [Environment]::NewLine if (Test-Path -LiteralPath $path) { - $rawLines = @(Get-Content -LiteralPath $path) + $bytes = [System.IO.File]::ReadAllBytes($path) + $offset = 0 + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { + $writeUtf8Bom = $true + $offset = 3 + } + + # TOML is UTF-8. Decode strictly so an unexpected legacy encoding stops + # the update instead of being silently converted into mojibake. + $strictUtf8 = [System.Text.UTF8Encoding]::new($false, $true) + $text = $strictUtf8.GetString($bytes, $offset, $bytes.Length - $offset) + $newlineMatch = [regex]::Match($text, "\r\n|\n|\r") + if ($newlineMatch.Success) { + $newline = $newlineMatch.Value + } + + $rawLines = if ([string]::IsNullOrEmpty($text)) { + @() + } + else { + @([regex]::Split($text, "\r\n|\n|\r")) + } if ($rawLines.Count -eq 1 -and [string]::IsNullOrEmpty($rawLines[0])) { $lines = @() } @@ -668,7 +691,12 @@ function Set-CodexMcpServer { } } - Set-Content -LiteralPath $path -Value $lines -Encoding utf8 + $content = $lines -join $newline + if ($lines.Count -gt 0) { + $content += $newline + } + $utf8 = [System.Text.UTF8Encoding]::new($writeUtf8Bom) + [System.IO.File]::WriteAllText($path, $content, $utf8) } function Ensure-McpServer { diff --git a/skills/scripts/smoke.ps1 b/skills/scripts/smoke.ps1 index e6aa2df..fc7fc66 100644 --- a/skills/scripts/smoke.ps1 +++ b/skills/scripts/smoke.ps1 @@ -71,7 +71,8 @@ $scripts = @( 'append-evidence.ps1', 'case-guard.ps1', 'test-routing.ps1', - 'extract-summaries.ps1' + 'extract-summaries.ps1', + 'test-bootstrap-codex-encoding.ps1' ) $parseOk = 0 $parseFail = 0 diff --git a/skills/scripts/test-bootstrap-codex-encoding.ps1 b/skills/scripts/test-bootstrap-codex-encoding.ps1 new file mode 100644 index 0000000..3e06173 --- /dev/null +++ b/skills/scripts/test-bootstrap-codex-encoding.ps1 @@ -0,0 +1,111 @@ +#Requires -Version 5.1 +param( + [string]$ScratchDir = '' +) + +$ErrorActionPreference = 'Stop' +$scriptDir = $PSScriptRoot +if ([string]::IsNullOrWhiteSpace($ScratchDir)) { + $ScratchDir = Join-Path ([System.IO.Path]::GetTempPath()) ('reverse-skill-codex-encoding-' + [guid]::NewGuid().ToString('N')) +} +New-Item -ItemType Directory -Path $ScratchDir -Force | Out-Null + +. (Join-Path $scriptDir 'lib\ToolDiscovery.ps1') + +# Load only the functions under test; do not execute bootstrap installation code. +$bootstrapPath = Join-Path $scriptDir 'bootstrap-reverse.ps1' +$tokens = $null +$errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($bootstrapPath, [ref]$tokens, [ref]$errors) +if ($errors.Count -gt 0) { + throw "bootstrap-reverse.ps1 parse failed: $($errors[0].Message)" +} +foreach ($name in @('ConvertTo-TomlLiteral', 'Remove-CodexMcpServerBlocks', 'Set-CodexMcpServer')) { + $functionAst = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name + }, $true) + if ($null -eq $functionAst) { + throw "Function not found: $name" + } + Invoke-Expression $functionAst.Extent.Text +} + +function Assert-True { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { + throw "ASSERTION FAILED: $Message" + } + Write-Host "[OK] $Message" -ForegroundColor Green +} + +function Test-CodexConfigRoundTrip { + param( + [string]$Name, + [bool]$WithBom, + [string]$Newline + ) + + $path = Join-Path $ScratchDir "$Name.toml" + $original = @( + "[projects.'e:\codex目录\blender脚本管理器']", + 'trust_level = "trusted"', + '', + '[mcp_servers.existing]', + 'command = "existing"' + ) -join $Newline + $original += $Newline + [System.IO.File]::WriteAllText($path, $original, [System.Text.UTF8Encoding]::new($WithBom)) + + $oldConfigPath = $env:CODEX_CONFIG_PATH + try { + $env:CODEX_CONFIG_PATH = $path + Set-CodexMcpServer -ServerName 'encoding-test' -ServerDefinition @{ + type = 'stdio' + command = 'cmd' + args = @('/c', 'echo', '中文参数') + } + } + finally { + $env:CODEX_CONFIG_PATH = $oldConfigPath + } + + $bytes = [System.IO.File]::ReadAllBytes($path) + $hasBom = $bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF + Assert-True ($hasBom -eq $WithBom) "$Name preserves UTF-8 BOM state" + + $text = [System.Text.Encoding]::UTF8.GetString($bytes) + if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { + $text = $text.Substring(1) + } + Assert-True ($text.Contains("[projects.'e:\codex目录\blender脚本管理器']")) "$Name preserves Chinese TOML path and closing quote" + Assert-True ($text.Contains('[mcp_servers.encoding-test]')) "$Name adds MCP server" + Assert-True ($text.Contains('中文参数')) "$Name preserves new Chinese values" + if ($Newline -eq "`r`n") { + Assert-True (-not ($text -replace "`r`n", '').Contains("`n")) "$Name preserves CRLF newlines" + } +} + +Test-CodexConfigRoundTrip -Name 'utf8-no-bom-crlf' -WithBom $false -Newline "`r`n" +Test-CodexConfigRoundTrip -Name 'utf8-bom-lf' -WithBom $true -Newline "`n" + +$invalidPath = Join-Path $ScratchDir 'invalid-utf8.toml' +[System.IO.File]::WriteAllBytes($invalidPath, [byte[]](0x5B, 0x80, 0x5D)) +$oldConfigPath = $env:CODEX_CONFIG_PATH +$threw = $false +try { + $env:CODEX_CONFIG_PATH = $invalidPath + Set-CodexMcpServer -ServerName 'must-not-write' -ServerDefinition @{ command = 'x' } +} +catch [System.Text.DecoderFallbackException] { + $threw = $true +} +finally { + $env:CODEX_CONFIG_PATH = $oldConfigPath +} +Assert-True $threw 'invalid UTF-8 fails closed instead of rewriting the file' +$invalidBytes = [System.IO.File]::ReadAllBytes($invalidPath) +Assert-True ($invalidBytes.Length -eq 3 -and $invalidBytes[1] -eq 0x80) 'invalid UTF-8 file remains unchanged' + +Write-Host 'OVERALL: ALL PASS' -ForegroundColor Green +