Skip to content

Commit 9772cd8

Browse files
committed
fix(windows): require exact cleanup process identity
1 parent 3af1d6d commit 9772cd8

5 files changed

Lines changed: 169 additions & 29 deletions

File tree

windows/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Windows Changelog
22

3+
## 未发布
4+
5+
### 安全
6+
7+
- injector 与托盘清理现在会解析完整命令行参数,只接受 Dream Skin 自己生成的独立参数;脚本路径、`--watch`、端口或 Browser ID 仅作为其他参数中的文本出现时,不再被当作进程身份依据。
8+
- Restore 只会终止以真实 PowerShell `-File` 模式运行受管托盘脚本的进程;无法枚举、停止或确认退出时会保留 state 并中止恢复,不再把失败降级成警告后继续修改配置。
9+
310
## 1.2.0 — 2026-07-17
411

512
### 新增

windows/references/runtime-notes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- If Codex is already running without the chosen debugging port, the shortcut asks before restart; CLI callers must close it or explicitly pass `-RestartExisting`.
1111
- Restore does not require Node to remain installed: it preflights config backups, closes Codex to clear live DOM and CDP, stops only the verified recorded injector, applies requested config changes, then reopens the official app without debug flags.
1212
- A live recorded injector whose PID no longer matches the saved Node path, injector command line, port, Browser ID, or start time causes start/restore to abort with state preserved; it is never silently archived and replaced.
13+
- Injector and tray cleanup parse a strict subset of the command-line form emitted by Dream Skin. Paths and identity options must be complete arguments, and the tray path must follow PowerShell's actual `-File` selector; ambiguous, malformed, or command-mode lines fail closed instead of triggering process termination.
1314
- The managed theme root rejects junctions and symbolic links before initialization, import, save, switch, pause, or state writes. Windows uses the bundled Node image-metadata helper to enforce the same 16 MB, 16384px, and 50MP limits before an import is copied.
1415
- `config.toml` is read from raw bytes as strict UTF-8, written without BOM through same-directory atomic replacement, and backed up byte-for-byte. Install requires Codex to be closed; writes stage the temporary file first, then abort if the destination bytes changed immediately before replacement. Quoted keys and table-header comments are supported; escaped target keys, multiline strings/arrays, dotted target keys, or duplicate target keys fail before writing. Completed restore backups are retained as `config.restored-*.toml` so reinstall captures a fresh baseline.
1516
- A per-user named mutex prevents concurrent install, start, restore, and verify operations from racing state, ports, or config writes.

windows/scripts/common-windows.ps1

Lines changed: 111 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -244,11 +244,117 @@ function Install-DreamSkinRuntimeEngine {
244244
}
245245
}
246246

247+
function ConvertFrom-DreamSkinStrictCommandLine {
248+
param([string]$CommandLine)
249+
if (-not $CommandLine) { return $null }
250+
251+
$tokens = [System.Collections.Generic.List[string]]::new()
252+
$index = 0
253+
while ($index -lt $CommandLine.Length) {
254+
while ($index -lt $CommandLine.Length -and [char]::IsWhiteSpace($CommandLine[$index])) {
255+
$index++
256+
}
257+
if ($index -ge $CommandLine.Length) { break }
258+
259+
$quoted = $CommandLine[$index] -eq '"'
260+
if ($quoted) { $index++ }
261+
$builder = [System.Text.StringBuilder]::new()
262+
$closed = -not $quoted
263+
while ($index -lt $CommandLine.Length) {
264+
$character = $CommandLine[$index]
265+
if ($quoted -and $character -eq '"') {
266+
$closed = $true
267+
$index++
268+
break
269+
}
270+
if (-not $quoted -and [char]::IsWhiteSpace($character)) { break }
271+
if (-not $quoted -and $character -eq '"') { return $null }
272+
[void]$builder.Append($character)
273+
$index++
274+
}
275+
if (-not $closed) { return $null }
276+
if ($index -lt $CommandLine.Length -and -not [char]::IsWhiteSpace($CommandLine[$index])) {
277+
return $null
278+
}
279+
$tokens.Add($builder.ToString())
280+
}
281+
return $tokens.ToArray()
282+
}
283+
247284
function Test-DreamSkinCommandLineToken {
248285
param([string]$CommandLine, [string]$Token)
249286
if (-not $CommandLine -or -not $Token) { return $false }
250-
$pattern = '(?i)(?:^|[\s"])' + [regex]::Escape($Token) + '(?=$|[\s"])'
251-
return [regex]::IsMatch($CommandLine, $pattern)
287+
$arguments = @(ConvertFrom-DreamSkinStrictCommandLine -CommandLine $CommandLine)
288+
return @($arguments | Where-Object { $_ -ieq $Token }).Count -eq 1
289+
}
290+
291+
function Test-DreamSkinNamedArgument {
292+
param([string[]]$Arguments, [string]$Name, [string]$Value)
293+
if (-not $Arguments -or -not $Name -or $null -eq $Value) { return $false }
294+
$matches = 0
295+
for ($index = 0; $index -lt $Arguments.Count; $index++) {
296+
if ($Arguments[$index] -ieq $Name) {
297+
if ($index + 1 -ge $Arguments.Count -or $Arguments[$index + 1] -cne $Value) { return $false }
298+
$matches++
299+
$index++
300+
continue
301+
}
302+
$prefix = "$Name="
303+
if ($Arguments[$index].StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) {
304+
if ($Arguments[$index].Substring($prefix.Length) -cne $Value) { return $false }
305+
$matches++
306+
}
307+
}
308+
return $matches -eq 1
309+
}
310+
311+
function Test-DreamSkinInjectorCommandLine {
312+
param([string]$CommandLine, [string]$InjectorPath, [int]$Port, [string]$BrowserId)
313+
$arguments = @(ConvertFrom-DreamSkinStrictCommandLine -CommandLine $CommandLine)
314+
if ($arguments.Count -lt 3 -or
315+
-not (Test-DreamSkinPathEqual -Left $arguments[1] -Right $InjectorPath) -or
316+
$arguments[2] -cne '--watch') {
317+
return $false
318+
}
319+
return (Test-DreamSkinNamedArgument -Arguments $arguments -Name '--port' -Value "$Port") -and
320+
(Test-DreamSkinNamedArgument -Arguments $arguments -Name '--browser-id' -Value $BrowserId)
321+
}
322+
323+
function Test-DreamSkinPowerShellFileCommandLine {
324+
param([string]$CommandLine, [string]$ScriptPath)
325+
$arguments = @(ConvertFrom-DreamSkinStrictCommandLine -CommandLine $CommandLine)
326+
if ($arguments.Count -lt 3) { return $false }
327+
$hostName = [System.IO.Path]::GetFileName($arguments[0])
328+
if ($hostName -ine 'powershell.exe' -and $hostName -ine 'pwsh.exe') { return $false }
329+
330+
for ($index = 1; $index -lt $arguments.Count; $index++) {
331+
$argument = $arguments[$index]
332+
if ($argument -ieq '-File') {
333+
return $index + 1 -lt $arguments.Count -and
334+
(Test-DreamSkinPathEqual -Left $arguments[$index + 1] -Right $ScriptPath)
335+
}
336+
if ($argument -iin @('-NoProfile', '-NoLogo', '-NonInteractive', '-STA', '-MTA')) {
337+
continue
338+
}
339+
if ($argument -ieq '-WindowStyle') {
340+
if ($index + 1 -ge $arguments.Count -or
341+
$arguments[$index + 1] -inotmatch '^(Normal|Minimized|Maximized|Hidden)$') {
342+
return $false
343+
}
344+
$index++
345+
continue
346+
}
347+
if ($argument -ieq '-ExecutionPolicy') {
348+
if ($index + 1 -ge $arguments.Count -or
349+
$arguments[$index + 1] -inotmatch '^(AllSigned|Bypass|Default|RemoteSigned|Restricted|Undefined|Unrestricted)$') {
350+
return $false
351+
}
352+
$index++
353+
continue
354+
}
355+
return $false
356+
}
357+
return $false
252358
}
253359

254360
function ConvertTo-DreamSkinProcessArgument {
@@ -739,19 +845,9 @@ function Stop-DreamSkinRecordedInjector {
739845
$isNodeExecutable = [System.IO.Path]::GetFileName("$processPath") -ieq 'node.exe'
740846
$nodeMatches = -not $State.nodePath -or
741847
(Test-DreamSkinPathEqual -Left $processPath -Right "$($State.nodePath)")
742-
$injectorMatches = [bool]($expectedInjector -and
743-
(Test-DreamSkinCommandLineToken -CommandLine $commandLine -Token $expectedInjector) -and
744-
(Test-DreamSkinCommandLineToken -CommandLine $commandLine -Token '--watch'))
745-
if ($State.port) {
746-
$portPattern = '(?i)(?:^|\s)--port(?:=|\s+)' + [regex]::Escape("$($State.port)") + '(?=$|\s)'
747-
$injectorMatches = $injectorMatches -and [regex]::IsMatch($commandLine, $portPattern)
748-
} else {
749-
$injectorMatches = $false
750-
}
751-
if ($State.browserId) {
752-
$browserPattern = '(?:^|\s)(?i:--browser-id)(?:=|\s+)' + [regex]::Escape("$($State.browserId)") + '(?=$|\s)'
753-
$injectorMatches = $injectorMatches -and [regex]::IsMatch($commandLine, $browserPattern)
754-
}
848+
$injectorMatches = [bool]($expectedInjector -and $State.port -and $State.browserId -and
849+
(Test-DreamSkinInjectorCommandLine -CommandLine $commandLine `
850+
-InjectorPath $expectedInjector -Port ([int]$State.port) -BrowserId "$($State.browserId)"))
755851
$startedAt = Get-DreamSkinProcessStartedAt -ProcessId $processId
756852
$startMatches = -not $State.injectorStartedAt -or $startedAt -eq "$($State.injectorStartedAt)"
757853
$identityMatches = [bool]($isNodeExecutable -and $nodeMatches -and $injectorMatches -and $startMatches)

windows/scripts/restore-dream-skin.ps1

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,18 @@ $PortExplicit = $PSBoundParameters.ContainsKey('Port')
1616

1717
function Stop-DreamSkinTrayProcess {
1818
$trayScript = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot 'tray-dream-skin.ps1'))
19-
try {
20-
$processes = Get-CimInstance Win32_Process -Filter "Name = 'powershell.exe' OR Name = 'pwsh.exe'" `
21-
-ErrorAction Stop
22-
foreach ($process in $processes) {
23-
if ($process.ProcessId -eq $PID -or -not $process.CommandLine) { continue }
24-
if ($process.CommandLine.IndexOf($trayScript, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) {
25-
Stop-Process -Id $process.ProcessId -Force -ErrorAction Stop
26-
}
19+
$processes = Get-CimInstance Win32_Process -Filter "Name = 'powershell.exe' OR Name = 'pwsh.exe'" `
20+
-ErrorAction Stop
21+
foreach ($process in $processes) {
22+
if ($process.ProcessId -eq $PID -or -not $process.CommandLine) { continue }
23+
if (-not (Test-DreamSkinPowerShellFileCommandLine `
24+
-CommandLine "$($process.CommandLine)" -ScriptPath $trayScript)) { continue }
25+
26+
Stop-Process -Id $process.ProcessId -Force -ErrorAction Stop
27+
try { Wait-Process -Id $process.ProcessId -Timeout 5 -ErrorAction Stop } catch {}
28+
if (Get-Process -Id $process.ProcessId -ErrorAction SilentlyContinue) {
29+
throw "Dream Skin tray process $($process.ProcessId) did not stop."
2730
}
28-
} catch {
29-
Write-Warning "Could not close the Dream Skin tray automatically: $($_.Exception.Message)"
3031
}
3132
}
3233

windows/tests/run-tests.ps1

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -489,10 +489,40 @@ try {
489489
throw 'Accepted an inconsistent CDP page target.'
490490
}
491491
}
492-
$watchCommand = '"C:\Program Files\nodejs\node.exe" "C:\Dream Skin\injector.mjs" --watch --port 9335 --browser-id browser-123'
493-
if (-not (Test-DreamSkinCommandLineToken -CommandLine $watchCommand -Token 'C:\Dream Skin\injector.mjs') -or
494-
(Test-DreamSkinCommandLineToken -CommandLine $watchCommand -Token 'Dream Skin\injector.mjs')) {
495-
throw 'Injector command-line token validation is not boundary-safe.'
492+
$injectorPath = 'C:\Dream Skin\engine\scripts\injector.mjs'
493+
$validInjectorCommand = '"C:\Program Files\nodejs\node.exe" "C:\Dream Skin\engine\scripts\injector.mjs" --watch --port 9335 --browser-id browser-1 --theme-dir "C:\Dream Skin\active-theme" --pause-file "C:\Dream Skin\paused"'
494+
$embeddedInjectorCommand = '"C:\Program Files\nodejs\node.exe" "harmless C:\Dream Skin\engine\scripts\injector.mjs --watch --port 9335 --browser-id browser-1 text"'
495+
if (-not (Test-DreamSkinInjectorCommandLine -CommandLine $validInjectorCommand `
496+
-InjectorPath $injectorPath -Port 9335 -BrowserId 'browser-1')) {
497+
throw 'Rejected the exact Dream Skin injector command line.'
498+
}
499+
if (Test-DreamSkinInjectorCommandLine -CommandLine $embeddedInjectorCommand `
500+
-InjectorPath $injectorPath -Port 9335 -BrowserId 'browser-1') {
501+
throw 'Accepted injector identity text embedded inside an unrelated argument.'
502+
}
503+
$duplicateInjectorCommand = $validInjectorCommand + ' --port 9335'
504+
if (Test-DreamSkinInjectorCommandLine -CommandLine $duplicateInjectorCommand `
505+
-InjectorPath $injectorPath -Port 9335 -BrowserId 'browser-1') {
506+
throw 'Accepted duplicate injector identity options.'
507+
}
508+
$malformedInjectorCommand = '"C:\Program Files\nodejs\node.exe" "C:\Dream Skin\engine\scripts\injector.mjs --watch --port 9335 --browser-id browser-1'
509+
if (Test-DreamSkinInjectorCommandLine -CommandLine $malformedInjectorCommand `
510+
-InjectorPath $injectorPath -Port 9335 -BrowserId 'browser-1') {
511+
throw 'Accepted an unclosed quoted injector command line.'
512+
}
513+
514+
$trayPath = 'C:\Dream Skin\engine\scripts\tray-dream-skin.ps1'
515+
$validTrayCommand = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\Dream Skin\engine\scripts\tray-dream-skin.ps1" -Port 9335'
516+
$embeddedTrayCommand = 'powershell.exe -NoProfile -Command "Write-Output C:\Dream Skin\engine\scripts\tray-dream-skin.ps1"'
517+
if (-not (Test-DreamSkinPowerShellFileCommandLine -CommandLine $validTrayCommand -ScriptPath $trayPath)) {
518+
throw 'Rejected the exact Dream Skin tray -File command line.'
519+
}
520+
if (Test-DreamSkinPowerShellFileCommandLine -CommandLine $embeddedTrayCommand -ScriptPath $trayPath) {
521+
throw 'Accepted tray script text that was not the PowerShell -File argument.'
522+
}
523+
$commandModeTraySpoof = 'powershell.exe -NoProfile -Command Write-Output -File "C:\Dream Skin\engine\scripts\tray-dream-skin.ps1"'
524+
if (Test-DreamSkinPowerShellFileCommandLine -CommandLine $commandModeTraySpoof -ScriptPath $trayPath) {
525+
throw 'Accepted a tray path following PowerShell command mode as a -File selector.'
496526
}
497527
if (-not (Test-DreamSkinBrowserId -Value 'browser-123') -or
498528
(Test-DreamSkinBrowserId -Value 'browser 123')) {
@@ -774,6 +804,11 @@ try {
774804
if (-not $restoreSource.Contains('Stop-DreamSkinTrayProcess')) {
775805
throw 'Complete restore does not stop a separately launched tray process.'
776806
}
807+
if (-not $restoreSource.Contains('Test-DreamSkinPowerShellFileCommandLine') -or
808+
$restoreSource.Contains('.IndexOf($trayScript') -or
809+
$restoreSource.Contains('Could not close the Dream Skin tray automatically')) {
810+
throw 'Restore does not require exact, fail-closed tray process identity.'
811+
}
777812
if ($restoreSource.Contains('Start-Process -FilePath $relaunchCodex.Executable') -or
778813
-not $restoreSource.Contains('Start-DreamSkinCodex -Codex $relaunchCodex')) {
779814
throw 'Restore still executes the WindowsApps path instead of activating the registered package.'

0 commit comments

Comments
 (0)