Windowsのwake trialは、タスクを登録できたことでは完了しません。run固有marker、powercfg /waketimersの事前確認、利用者が明示的に行うsleep、trigger後の新しいsessionでのmarker・LastRunTime・LastTaskResultの一致をそろえて初めて、実際にwakeした試験として合格にします。
run-owned markerと一意なtask計画をCreateNewで保存する
GUIDのOperationIdと暗号学的nonceをtask名・description・markerへ結び付けます。triggerは秒境界へ丸め、少なくとも5分以上先であることを要求します。markerは予定actionだけがCreateNewできる非存在pathであり、state rootがreparse pointなら停止します。
$ErrorActionPreference='Stop'
$operationId=[guid]::NewGuid().ToString('D')
$markerNonce=[guid]::NewGuid().ToString('N')
$taskName="ITtrip-WakeTrial-$operationId"
$taskPath='\'
$runAt=(Get-Date).AddMinutes(15)
$runAt=$runAt.AddTicks(-($runAt.Ticks % [TimeSpan]::TicksPerSecond))
if($runAt -le (Get-Date).AddMinutes(5)){throw 'trigger must remain more than five minutes in the future'}
$stateRoot=Join-Path 'C:\ProgramData\ITtrip\WakeTrials' $operationId
if(Test-Path -LiteralPath $stateRoot){throw "run-owned state root already exists: $stateRoot"}
$null=New-Item -ItemType Directory -Path $stateRoot -ErrorAction Stop
$rootItem=Get-Item -LiteralPath $stateRoot -Force -ErrorAction Stop
if($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint){throw 'state root is a reparse point'}
$markerPath=Join-Path $stateRoot 'executed.json'
$statePath=Join-Path $stateRoot 'state.json'
if(Test-Path -LiteralPath $markerPath){throw 'marker must not exist before registration'}
$actionScript=@'
$ErrorActionPreference='Stop'
$payload=[ordered]@{OperationId='@@OP@@';MarkerNonce='@@NONCE@@';TriggerUtc='@@TRIGGER@@';ExecutedUtc=(Get-Date).ToUniversalTime().ToString('o')}
$bytes=[Text.Encoding]::UTF8.GetBytes(($payload|ConvertTo-Json -Compress))
$stream=[IO.File]::Open('@@MARKER@@',[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
try{$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)}finally{$stream.Dispose()}
'@
$actionScript=$actionScript.Replace('@@OP@@',$operationId).Replace('@@NONCE@@',$markerNonce).Replace('@@TRIGGER@@',$runAt.ToUniversalTime().ToString('o')).Replace('@@MARKER@@',$markerPath.Replace("'","''"))
$encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($actionScript))
$actionExecute=Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe'
if(-not (Test-Path -LiteralPath $actionExecute -PathType Leaf)){throw 'Windows PowerShell executable not found'}
$actionArguments="-NoProfile -NonInteractive -EncodedCommand $encoded"
$description="ITtripWakeTrial OperationId=$operationId MarkerNonce=$markerNonce"
$state=[ordered]@{OperationId=$operationId;MarkerNonce=$markerNonce;Stage='Planned';TaskName=$taskName;TaskPath=$taskPath;TriggerUtc=$runAt.ToUniversalTime().ToString('o');ActionExecute=$actionExecute;ActionArguments=$actionArguments;PrincipalUser='SYSTEM';PrincipalLogonType='ServiceAccount';PrincipalRunLevel='Highest';WakeToRun=$true;StartWhenAvailable=$true;MultipleInstances='IgnoreNew';Description=$description;MarkerPath=$markerPath;StatePath=$statePath;CreatedUtc=(Get-Date).ToUniversalTime().ToString('o')}
$stateBytes=[Text.Encoding]::UTF8.GetBytes(($state|ConvertTo-Json -Depth 8))
$stateStream=[IO.File]::Open($statePath,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
try{$stateStream.Write($stateBytes,0,$stateBytes.Length);$stateStream.Flush($true)}finally{$stateStream.Dispose()}
[pscustomobject]@{Stage=$state.Stage;TaskName=$taskName;TriggerLocal=$runAt;MarkerPath=$markerPath;StatePath=$statePath}
task定義をexact verifyしwake timerを事前確認する
action・argument・trigger・SYSTEM/ServiceAccount/Highest principal・WakeToRun・StartWhenAvailable・IgnoreNew・descriptionを登録直後に完全照合します。さらにpowercfg /waketimersがexact task名を返さない限り、sleepへ進みません。NextRunTimeだけではactual wakeの証拠になりません。
$state=Get-Content -LiteralPath $statePath -Raw -ErrorAction Stop|ConvertFrom-Json
if($state.Stage -ne 'Planned'){throw 'registration requires Planned state'}
if((Get-Date).ToUniversalTime() -ge ([datetime]$state.TriggerUtc).ToUniversalTime().AddMinutes(-5)){throw 'trigger is now too close; make a new plan'}
if(@(Get-ScheduledTask -TaskName $state.TaskName -TaskPath $state.TaskPath -ErrorAction SilentlyContinue).Count){throw 'exact task identity already exists'}
$action=New-ScheduledTaskAction -Execute $state.ActionExecute -Argument $state.ActionArguments
$trigger=New-ScheduledTaskTrigger -Once -At ([datetime]$state.TriggerUtc).ToLocalTime()
$principal=New-ScheduledTaskPrincipal -UserId $state.PrincipalUser -LogonType ServiceAccount -RunLevel Highest
$settings=New-ScheduledTaskSettingsSet -WakeToRun -StartWhenAvailable -MultipleInstances IgnoreNew
$definition=New-ScheduledTask -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Description $state.Description
$null=Register-ScheduledTask -TaskName $state.TaskName -TaskPath $state.TaskPath -InputObject $definition -ErrorAction Stop
$live=@(Get-ScheduledTask -TaskName $state.TaskName -TaskPath $state.TaskPath -ErrorAction Stop)
if($live.Count -ne 1){throw 'registered task is not exactly one'}
$owned=$live[0]
if(@($owned.Actions).Count -ne 1 -or $owned.Actions[0].Execute -cne $state.ActionExecute -or $owned.Actions[0].Arguments -cne $state.ActionArguments){throw 'task action ownership mismatch'}
if(@($owned.Triggers).Count -ne 1 -or ([datetime]$owned.Triggers[0].StartBoundary).ToUniversalTime() -ne ([datetime]$state.TriggerUtc).ToUniversalTime()){throw 'task trigger ownership mismatch'}
if($owned.Principal.UserId -cne $state.PrincipalUser -or [string]$owned.Principal.LogonType -cne $state.PrincipalLogonType -or [string]$owned.Principal.RunLevel -cne $state.PrincipalRunLevel){throw 'task principal ownership mismatch'}
if(-not $owned.Settings.WakeToRun -or -not $owned.Settings.StartWhenAvailable -or [string]$owned.Settings.MultipleInstances -cne $state.MultipleInstances -or $owned.Description -cne $state.Description){throw 'task settings or description mismatch'}
$wakeTimerOutput=@(& powercfg.exe /waketimers 2>&1|ForEach-Object {[string]$_})
if($LASTEXITCODE -ne 0){throw "powercfg /waketimers failed: $LASTEXITCODE"}
if(($wakeTimerOutput -join [Environment]::NewLine) -notmatch [regex]::Escape([string]$state.TaskName)){throw 'exact task was not found in powercfg /waketimers'}
$hashAlgorithm=[Security.Cryptography.SHA256]::Create()
try{$wakeTimerHashBytes=$hashAlgorithm.ComputeHash([Text.Encoding]::UTF8.GetBytes(($wakeTimerOutput -join [Environment]::NewLine)))}finally{$hashAlgorithm.Dispose()}
$state.Stage='WakeTimerVerified';$state.WakeTimerVerifiedUtc=(Get-Date).ToUniversalTime().ToString('o');$state.WakeTimerEvidenceSha256=(-join @($wakeTimerHashBytes|ForEach-Object{$_.ToString('x2')}))
$state|ConvertTo-Json -Depth 8|Set-Content -LiteralPath $statePath -Encoding UTF8 -ErrorAction Stop
[pscustomobject]@{Stage=$state.Stage;TaskName=$state.TaskName;TriggerUtc=$state.TriggerUtc;WakeTimerMatched=$true;MarkerStillAbsent=(-not (Test-Path -LiteralPath $state.MarkerPath))}
利用者が承認してStartメニューからsleepへ入る
sleep直前にownershipとwake timerをもう一度照合し、OperationId・task・trigger・nonceを含むtokenを要求します。rundll32などの自動sleepは使わず、利用者がStartメニューから明示的にsleepを選びます。
$statePath='PASTE_STATE_PATH_FROM_PLAN'
$state=Get-Content -LiteralPath $statePath -Raw -ErrorAction Stop|ConvertFrom-Json
if($state.Stage -ne 'WakeTimerVerified'){throw 'sleep approval requires WakeTimerVerified state'}
if(Test-Path -LiteralPath $state.MarkerPath){throw 'marker appeared before the approved sleep interval'}
$triggerUtc=([datetime]$state.TriggerUtc).ToUniversalTime()
if((Get-Date).ToUniversalTime() -ge $triggerUtc.AddMinutes(-2)){throw 'insufficient time remains to enter sleep; cancel and make a new plan'}
$live=@(Get-ScheduledTask -TaskName $state.TaskName -TaskPath $state.TaskPath -ErrorAction Stop)
if($live.Count -ne 1 -or @($live[0].Actions).Count -ne 1 -or $live[0].Actions[0].Execute -cne $state.ActionExecute -or $live[0].Actions[0].Arguments -cne $state.ActionArguments -or @($live[0].Triggers).Count -ne 1 -or ([datetime]$live[0].Triggers[0].StartBoundary).ToUniversalTime() -ne $triggerUtc -or $live[0].Principal.UserId -cne $state.PrincipalUser -or [string]$live[0].Principal.LogonType -cne $state.PrincipalLogonType -or [string]$live[0].Principal.RunLevel -cne $state.PrincipalRunLevel -or -not $live[0].Settings.WakeToRun -or -not $live[0].Settings.StartWhenAvailable -or [string]$live[0].Settings.MultipleInstances -cne $state.MultipleInstances -or $live[0].Description -cne $state.Description){throw 'task ownership changed before sleep approval'}
$wakeTimerOutput=@(& powercfg.exe /waketimers 2>&1|ForEach-Object {[string]$_})
if($LASTEXITCODE -ne 0 -or ($wakeTimerOutput -join [Environment]::NewLine) -notmatch [regex]::Escape([string]$state.TaskName)){throw 'exact wake timer is no longer armed'}
$token="APPROVE-MANUAL-SLEEP OP=$($state.OperationId) TASK=$($state.TaskName) TRIGGER=$($state.TriggerUtc) NONCE=$($state.MarkerNonce)"
if((Read-Host "Start menuからスリープへ入る直前に次を入力: $token") -cne $token){throw 'manual sleep was not approved'}
$state.Stage='ArmedForUserSleep';$state.SleepApprovedUtc=(Get-Date).ToUniversalTime().ToString('o')
$state|ConvertTo-Json -Depth 8|Set-Content -LiteralPath $statePath -Encoding UTF8 -ErrorAction Stop
Write-Host 'このコードは自動でスリープしません。今すぐ Start > Power > Sleep を選び、trigger後に新しいPowerShell sessionで検証してください。'
trigger後のfresh sessionでactual wake evidenceを確定する
新しいPowerShell sessionでstateを読み、trigger経過後にtask所有権を再照合します。markerのOperationId・nonce・trigger・ExecutedUtc、Get-ScheduledTaskInfoのLastRunTimeとLastTaskResult=0がすべて一致しなければ失敗です。
$statePath='PASTE_STATE_PATH_IN_A_FRESH_SESSION'
$state=Get-Content -LiteralPath $statePath -Raw -ErrorAction Stop|ConvertFrom-Json
if($state.Stage -ne 'ArmedForUserSleep'){throw 'fresh-session verification requires ArmedForUserSleep state'}
$nowUtc=(Get-Date).ToUniversalTime();$triggerUtc=([datetime]$state.TriggerUtc).ToUniversalTime()
if($nowUtc -lt $triggerUtc){throw 'trigger time has not passed; registration alone is incomplete'}
$live=@(Get-ScheduledTask -TaskName $state.TaskName -TaskPath $state.TaskPath -ErrorAction Stop)
if($live.Count -ne 1 -or @($live[0].Actions).Count -ne 1 -or $live[0].Actions[0].Execute -cne $state.ActionExecute -or $live[0].Actions[0].Arguments -cne $state.ActionArguments -or @($live[0].Triggers).Count -ne 1 -or ([datetime]$live[0].Triggers[0].StartBoundary).ToUniversalTime() -ne $triggerUtc -or $live[0].Principal.UserId -cne $state.PrincipalUser -or [string]$live[0].Principal.LogonType -cne $state.PrincipalLogonType -or [string]$live[0].Principal.RunLevel -cne $state.PrincipalRunLevel -or -not $live[0].Settings.WakeToRun -or -not $live[0].Settings.StartWhenAvailable -or [string]$live[0].Settings.MultipleInstances -cne $state.MultipleInstances -or $live[0].Description -cne $state.Description){throw 'task ownership changed before post-trigger verification'}
if(-not (Test-Path -LiteralPath $state.MarkerPath -PathType Leaf)){throw 'run-owned marker is missing'}
$markerItem=Get-Item -LiteralPath $state.MarkerPath -Force -ErrorAction Stop
if($markerItem.Attributes -band [IO.FileAttributes]::ReparsePoint){throw 'marker is a reparse point'}
$marker=Get-Content -LiteralPath $state.MarkerPath -Raw -ErrorAction Stop|ConvertFrom-Json
$executedUtc=([datetime]$marker.ExecutedUtc).ToUniversalTime()
if($marker.OperationId -cne $state.OperationId -or $marker.MarkerNonce -cne $state.MarkerNonce -or ([datetime]$marker.TriggerUtc).ToUniversalTime() -ne $triggerUtc -or $executedUtc -lt $triggerUtc -or $executedUtc -gt $nowUtc){throw 'marker ownership or timing mismatch'}
$info=Get-ScheduledTaskInfo -TaskName $state.TaskName -TaskPath $state.TaskPath -ErrorAction Stop
if($info.LastRunTime.ToUniversalTime() -lt $triggerUtc){throw 'LastRunTime is before the bound trigger'}
if([int]$info.LastTaskResult -ne 0){throw "LastTaskResult is not zero: $($info.LastTaskResult)"}
$state.Stage='ActualWakeTrialVerified';$state.VerifiedUtc=$nowUtc.ToString('o');$state.ExecutedUtc=$executedUtc.ToString('o');$state.LastRunTimeUtc=$info.LastRunTime.ToUniversalTime().ToString('o');$state.LastTaskResult=[int]$info.LastTaskResult;$state.MarkerSha256=(Get-FileHash -LiteralPath $state.MarkerPath -Algorithm SHA256 -ErrorAction Stop).Hash
$state|ConvertTo-Json -Depth 8|Set-Content -LiteralPath $statePath -Encoding UTF8 -ErrorAction Stop
[pscustomobject]@{Stage=$state.Stage;ExecutedUtc=$state.ExecutedUtc;LastRunTimeUtc=$state.LastRunTimeUtc;LastTaskResult=$state.LastTaskResult;ActualWakeEvidence=$true}
検証済みrunだけを別承認でcleanupする
cleanupはactual wake合格後の別操作です。taskの全所有権fieldとmarker hashを再bindし、別tokenを受けた場合だけexact TaskPath/TaskNameとmarkerを削除します。state evidenceは残し、他taskや親directoryを削除しません。
$statePath='PASTE_VERIFIED_STATE_PATH'
$state=Get-Content -LiteralPath $statePath -Raw -ErrorAction Stop|ConvertFrom-Json
if($state.Stage -ne 'ActualWakeTrialVerified'){throw 'cleanup requires verified actual-wake evidence'}
$triggerUtc=([datetime]$state.TriggerUtc).ToUniversalTime()
$live=@(Get-ScheduledTask -TaskName $state.TaskName -TaskPath $state.TaskPath -ErrorAction Stop)
if($live.Count -ne 1 -or @($live[0].Actions).Count -ne 1 -or $live[0].Actions[0].Execute -cne $state.ActionExecute -or $live[0].Actions[0].Arguments -cne $state.ActionArguments -or @($live[0].Triggers).Count -ne 1 -or ([datetime]$live[0].Triggers[0].StartBoundary).ToUniversalTime() -ne $triggerUtc -or $live[0].Principal.UserId -cne $state.PrincipalUser -or [string]$live[0].Principal.LogonType -cne $state.PrincipalLogonType -or [string]$live[0].Principal.RunLevel -cne $state.PrincipalRunLevel -or -not $live[0].Settings.WakeToRun -or -not $live[0].Settings.StartWhenAvailable -or [string]$live[0].Settings.MultipleInstances -cne $state.MultipleInstances -or $live[0].Description -cne $state.Description){throw 'cleanup refused: task ownership changed'}
$markerItem=Get-Item -LiteralPath $state.MarkerPath -Force -ErrorAction Stop
if(-not $markerItem.PSIsContainer -and -not ($markerItem.Attributes -band [IO.FileAttributes]::ReparsePoint)){$marker=Get-Content -LiteralPath $state.MarkerPath -Raw -ErrorAction Stop|ConvertFrom-Json}else{throw 'cleanup refused: marker is not an owned regular leaf'}
if($marker.OperationId -cne $state.OperationId -or $marker.MarkerNonce -cne $state.MarkerNonce -or ([datetime]$marker.TriggerUtc).ToUniversalTime() -ne $triggerUtc -or (Get-FileHash -LiteralPath $state.MarkerPath -Algorithm SHA256).Hash -cne $state.MarkerSha256){throw 'cleanup refused: marker ownership changed'}
$token="CLEANUP-WAKE-TRIAL OP=$($state.OperationId) TASK=$($state.TaskPath)$($state.TaskName) NONCE=$($state.MarkerNonce)"
if((Read-Host "検証済みrunだけを削除する場合は入力: $token") -cne $token){throw 'cleanup was not approved'}
Unregister-ScheduledTask -TaskName $state.TaskName -TaskPath $state.TaskPath -Confirm:$false -ErrorAction Stop
if(@(Get-ScheduledTask -TaskName $state.TaskName -TaskPath $state.TaskPath -ErrorAction SilentlyContinue).Count){throw 'exact task still exists after cleanup'}
Remove-Item -LiteralPath $state.MarkerPath -ErrorAction Stop
if(Test-Path -LiteralPath $state.MarkerPath){throw 'marker still exists after cleanup'}
$state.Stage='CleanupVerified';$state.CleanupVerifiedUtc=(Get-Date).ToUniversalTime().ToString('o')
$state|ConvertTo-Json -Depth 8|Set-Content -LiteralPath $statePath -Encoding UTF8 -ErrorAction Stop
[pscustomobject]@{Stage=$state.Stage;TaskAbsent=$true;MarkerAbsent=$true;StateEvidenceRetained=$statePath}
停止条件と環境差
marker欠落・古いmarker・trigger前LastRunTime・非zero Result・wake timer欠落・ownership差分はすべて不合格です。Modern Standby、firmware、battery policy、管理者権限によりwakeが抑止される場合は設定を推測変更せず、失敗evidenceを保存して端末管理者へ確認します。

コメント