@ran-sh/dsh-crew 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +16 -16
- package/.claude-plugin/plugin.json +14 -14
- package/LICENSE +21 -21
- package/README.de.md +359 -359
- package/README.es.md +359 -359
- package/README.fr.md +359 -359
- package/README.hi.md +359 -359
- package/README.id.md +359 -359
- package/README.ja.md +359 -359
- package/README.ko.md +359 -359
- package/README.md +126 -126
- package/README.pt.md +359 -359
- package/README.ru.md +359 -359
- package/README.th.md +359 -359
- package/README.tr.md +359 -359
- package/README.vi.md +359 -359
- package/README.zh-TW.md +359 -359
- package/README.zh.md +116 -116
- package/agents/ds-flash.md +24 -24
- package/agents/ds-pro.md +24 -24
- package/agents/ds-reviewer.md +23 -23
- package/agents/ds-worker.md +23 -23
- package/bin/dsh-crew.mjs +16 -16
- package/codex/agents/ds-flash.toml +34 -34
- package/codex/agents/ds-pro.toml +34 -34
- package/codex/agents/ds-reviewer.toml +32 -32
- package/codex/agents/ds-worker.toml +32 -32
- package/codex/prompts/dsh-config.md +19 -19
- package/codex/prompts/dsh-status.md +5 -5
- package/commands/config.md +23 -23
- package/commands/off.md +5 -5
- package/commands/on.md +5 -5
- package/commands/status.md +9 -9
- package/cordis.patch.yml +4 -4
- package/docs/gpt-relay-extension.md +103 -103
- package/docs/installation.md +138 -138
- package/docs/job-contracts.md +103 -103
- package/docs/readiness-matrix.md +85 -85
- package/docs/ui-surfaces.md +107 -107
- package/official-web-bridge/cordis.patch.yml +4 -4
- package/official-web-bridge/entry.mjs +1 -1
- package/official-web-bridge/overlay-entry.mjs +59 -59
- package/official-web-bridge/package.json +25 -25
- package/package.json +3 -2
- package/scripts/build-client.mjs +49 -49
- package/scripts/live-crew-smoke.mjs +39 -39
- package/scripts/live-policy-matrix.mjs +177 -177
- package/scripts/policy-probe.mjs +101 -101
- package/scripts/remove-legacy-official-bridge.ps1 +89 -89
- package/scripts/setup.mjs +393 -393
- package/scripts/smoke-real.mjs +110 -110
- package/scripts/smoke.mjs +78 -78
- package/scripts/verify-crew-ui-polish.mjs +145 -145
- package/scripts/verify-history-ui.mjs +97 -97
- package/scripts/verify-installer-fix.mjs +26 -26
- package/scripts/verify-npm-install.mjs +311 -311
- package/scripts/verify-official-bridge-e2e.mjs +192 -192
- package/src/adaptive-routing.mjs +260 -260
- package/src/client/activation-summary.tsx +64 -64
- package/src/client/collapsible-sections.mjs +55 -55
- package/src/client/history-panel.tsx +108 -108
- package/src/client/host-readiness.mjs +71 -71
- package/src/client/index.tsx +1711 -1711
- package/src/client/model-callability-view.mjs +17 -17
- package/src/client/panel-chrome.tsx +40 -40
- package/src/client/quick-entry.tsx +10 -10
- package/src/client/quick-panel.tsx +275 -275
- package/src/client/readiness-envelope.mjs +34 -34
- package/src/client/surface-detection.mjs +43 -43
- package/src/config-readiness.mjs +226 -226
- package/src/credential-reference.mjs +38 -38
- package/src/delivery.mjs +205 -205
- package/src/dsh-cli-runtime.mjs +1021 -1021
- package/src/dsh-cohort.mjs +20 -20
- package/src/extension-contract.mjs +104 -104
- package/src/failure-classification.mjs +201 -201
- package/src/history/admission-gate.mjs +67 -67
- package/src/history/archive-store.mjs +272 -272
- package/src/history/cleanup-plan.mjs +89 -89
- package/src/history/http.mjs +32 -32
- package/src/history/operation.mjs +86 -86
- package/src/history/runner-detach.mjs +34 -34
- package/src/history/runner.mjs +52 -52
- package/src/history/runtime.mjs +36 -36
- package/src/history/service.mjs +177 -177
- package/src/history/state.mjs +27 -27
- package/src/hub/entry.mjs +104 -104
- package/src/hub/index.mjs +2694 -2694
- package/src/hub-client.mjs +154 -154
- package/src/hub-compatibility.mjs +42 -42
- package/src/i18n.mjs +19 -19
- package/src/information-flow.mjs +67 -67
- package/src/install/cli.mjs +28 -28
- package/src/install/install-legacy.mjs +711 -711
- package/src/install/install.mjs +483 -483
- package/src/install/npx-lifecycle.mjs +3456 -3456
- package/src/install/official-frontend-assets.mjs +78 -78
- package/src/install/official-web.mjs +95 -95
- package/src/install/payload-content.mjs +88 -88
- package/src/install/windows-startup.mjs +236 -236
- package/src/install/windows-supervisor-adapter.mjs +443 -443
- package/src/install/windows-supervisor-lifecycle.mjs +782 -782
- package/src/install/zcode.mjs +397 -397
- package/src/job-contracts.mjs +255 -255
- package/src/local-request-guard.mjs +60 -60
- package/src/mcp-runtime.mjs +340 -340
- package/src/model-callability-contract.mjs +79 -79
- package/src/model-catalog.mjs +180 -180
- package/src/model-routing.mjs +586 -586
- package/src/model-schedule.mjs +207 -207
- package/src/official-web-bridge.mjs +447 -447
- package/src/policy.mjs +235 -235
- package/src/provider-delete-adapters.mjs +1934 -1934
- package/src/provider-health.mjs +130 -130
- package/src/provider-inventory.mjs +182 -182
- package/src/provider-layer-migration-adapters.mjs +759 -759
- package/src/provider-layer-migration.mjs +198 -198
- package/src/provider-lifecycle-state.mjs +103 -103
- package/src/provider-lifecycle.mjs +252 -252
- package/src/provider-profile-store.mjs +390 -390
- package/src/provider-settings-store.mjs +633 -633
- package/src/provider-store-lock.mjs +67 -67
- package/src/readiness-matrix.mjs +181 -181
- package/src/removable-waiter.mjs +29 -29
- package/src/role-profiles.mjs +107 -107
- package/src/runtime-controls.mjs +84 -84
- package/src/runtime-identity-contract.mjs +34 -34
- package/src/runtime-identity.mjs +235 -235
- package/src/runtime-readiness-snapshot.mjs +285 -285
- package/src/server.mjs +581 -581
- package/src/session-origins.mjs +60 -60
- package/src/standalone-sdk.mjs +23 -23
- package/src/status-shard.mjs +63 -63
- package/src/structured-error-code.mjs +38 -38
- package/src/supervisor/restart-request.mjs +256 -256
- package/src/workflow-runtime.mjs +739 -739
- package/src/workflow.mjs +155 -155
- package/src/workspace-audit.mjs +231 -231
- package/src/workspace-context.mjs +146 -146
- package/src/workspace-isolation.mjs +463 -455
- package/src/workspace-readiness.mjs +32 -32
- package/statusline/statusline.sh +14 -14
- package/statusline/worker-segment.sh +35 -35
- package/windows/start-dsh-crew.cmd +57 -57
- package/windows/start-dsh-crew.ps1 +1302 -1302
- package/windows/supervisor-control.ps1 +467 -467
- package/worker.cordis.yml +67 -67
- package/zcode/agents/ds-reviewer.md +31 -31
- package/zcode/agents/ds-worker.md +31 -31
- package/zcode/commands/dsh-config.md +17 -17
- package/zcode/commands/dsh-status.md +5 -5
|
@@ -1,467 +1,467 @@
|
|
|
1
|
-
# DSH Crew Windows supervisor process control
|
|
2
|
-
#
|
|
3
|
-
# Reads one JSON request from stdin and writes exactly one JSON response to
|
|
4
|
-
# stdout. This helper deliberately controls one verified watcher PID at a
|
|
5
|
-
# time; it never uses process-tree termination.
|
|
6
|
-
[CmdletBinding()]
|
|
7
|
-
param()
|
|
8
|
-
|
|
9
|
-
Set-StrictMode -Version Latest
|
|
10
|
-
$ErrorActionPreference = 'Stop'
|
|
11
|
-
|
|
12
|
-
Add-Type -TypeDefinition @'
|
|
13
|
-
using System;
|
|
14
|
-
using System.Runtime.InteropServices;
|
|
15
|
-
|
|
16
|
-
namespace DshCrewSupervisorControl {
|
|
17
|
-
public static class NativeProcess {
|
|
18
|
-
private const int ProcessCommandLineInformation = 60;
|
|
19
|
-
|
|
20
|
-
[StructLayout(LayoutKind.Sequential)]
|
|
21
|
-
private struct UNICODE_STRING {
|
|
22
|
-
public ushort Length;
|
|
23
|
-
public ushort MaximumLength;
|
|
24
|
-
public IntPtr Buffer;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
[DllImport("ntdll.dll")]
|
|
28
|
-
private static extern int NtQueryInformationProcess(
|
|
29
|
-
IntPtr process,
|
|
30
|
-
int informationClass,
|
|
31
|
-
IntPtr information,
|
|
32
|
-
int informationLength,
|
|
33
|
-
out int returnLength);
|
|
34
|
-
|
|
35
|
-
public static string ReadCommandLine(IntPtr process) {
|
|
36
|
-
int length;
|
|
37
|
-
NtQueryInformationProcess(process, ProcessCommandLineInformation, IntPtr.Zero, 0, out length);
|
|
38
|
-
if (length <= 0 || length > 1024 * 1024) {
|
|
39
|
-
throw new InvalidOperationException("Process command line length is unavailable or unsafe.");
|
|
40
|
-
}
|
|
41
|
-
IntPtr buffer = Marshal.AllocHGlobal(length);
|
|
42
|
-
try {
|
|
43
|
-
int returned;
|
|
44
|
-
int status = NtQueryInformationProcess(process, ProcessCommandLineInformation, buffer, length, out returned);
|
|
45
|
-
if (status != 0) throw new InvalidOperationException("Process command line query failed.");
|
|
46
|
-
UNICODE_STRING value = (UNICODE_STRING)Marshal.PtrToStructure(buffer, typeof(UNICODE_STRING));
|
|
47
|
-
if (value.Buffer == IntPtr.Zero || value.Length == 0) return String.Empty;
|
|
48
|
-
return Marshal.PtrToStringUni(value.Buffer, value.Length / 2);
|
|
49
|
-
} finally {
|
|
50
|
-
Marshal.FreeHGlobal(buffer);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
'@ | Out-Null
|
|
56
|
-
|
|
57
|
-
function Throw-ControlFailure {
|
|
58
|
-
param(
|
|
59
|
-
[Parameter(Mandatory = $true)] [string] $Code,
|
|
60
|
-
[Parameter(Mandatory = $true)] [string] $Message
|
|
61
|
-
)
|
|
62
|
-
$failure = New-Object System.InvalidOperationException($Message)
|
|
63
|
-
$failure.Data['dsh_crew_control_code'] = $Code
|
|
64
|
-
throw $failure
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function Get-RequestProperty {
|
|
68
|
-
param(
|
|
69
|
-
[Parameter(Mandatory = $true)] [psobject] $Request,
|
|
70
|
-
[Parameter(Mandatory = $true)] [string] $Name,
|
|
71
|
-
[bool] $Required = $false
|
|
72
|
-
)
|
|
73
|
-
$property = $Request.PSObject.Properties[$Name]
|
|
74
|
-
if ($null -eq $property) {
|
|
75
|
-
if ($Required) { Throw-ControlFailure 'REQUEST_INVALID' ("Missing required field: {0}." -f $Name) }
|
|
76
|
-
return $null
|
|
77
|
-
}
|
|
78
|
-
return $property.Value
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function Get-IdentitySource {
|
|
82
|
-
param([Parameter(Mandatory = $true)] [psobject] $Request)
|
|
83
|
-
$expected = Get-RequestProperty -Request $Request -Name 'expected'
|
|
84
|
-
if ($null -ne $expected) {
|
|
85
|
-
if ($expected -isnot [psobject] -or $expected -is [string]) {
|
|
86
|
-
Throw-ControlFailure 'REQUEST_INVALID' 'The expected field must be an object.'
|
|
87
|
-
}
|
|
88
|
-
return $expected
|
|
89
|
-
}
|
|
90
|
-
return $Request
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function Get-CanonicalHelperPath {
|
|
94
|
-
param([object] $Value)
|
|
95
|
-
if ($Value -isnot [string] -or [string]::IsNullOrWhiteSpace($Value) -or $Value.Length -gt 32768) {
|
|
96
|
-
Throw-ControlFailure 'HELPER_PATH_INVALID' 'helper_path must be a non-empty absolute path.'
|
|
97
|
-
}
|
|
98
|
-
if (-not [System.IO.Path]::IsPathRooted($Value) -or $Value.Contains('"')) {
|
|
99
|
-
Throw-ControlFailure 'HELPER_PATH_INVALID' 'helper_path must be a safe absolute path.'
|
|
100
|
-
}
|
|
101
|
-
try {
|
|
102
|
-
$fullPath = [System.IO.Path]::GetFullPath($Value)
|
|
103
|
-
} catch {
|
|
104
|
-
Throw-ControlFailure 'HELPER_PATH_INVALID' 'helper_path could not be normalized.'
|
|
105
|
-
}
|
|
106
|
-
if (-not [string]::Equals([System.IO.Path]::GetFileName($fullPath), 'start-dsh-crew.ps1', [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
107
|
-
Throw-ControlFailure 'HELPER_PATH_INVALID' 'The managed helper must be named start-dsh-crew.ps1.'
|
|
108
|
-
}
|
|
109
|
-
if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) {
|
|
110
|
-
Throw-ControlFailure 'HELPER_PATH_NOT_FOUND' 'The expected managed helper does not exist.'
|
|
111
|
-
}
|
|
112
|
-
try {
|
|
113
|
-
return (Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop).FullName
|
|
114
|
-
} catch {
|
|
115
|
-
Throw-ControlFailure 'HELPER_PATH_INVALID' 'The expected managed helper cannot be inspected.'
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function Get-HelperHash {
|
|
120
|
-
param([Parameter(Mandatory = $true)] [string] $HelperPath)
|
|
121
|
-
$stream = $null
|
|
122
|
-
$sha256 = $null
|
|
123
|
-
try {
|
|
124
|
-
$stream = [System.IO.File]::Open($HelperPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
|
|
125
|
-
$sha256 = [System.Security.Cryptography.SHA256]::Create()
|
|
126
|
-
return ([System.BitConverter]::ToString($sha256.ComputeHash($stream))).Replace('-', '').ToLowerInvariant()
|
|
127
|
-
} catch {
|
|
128
|
-
Throw-ControlFailure 'HELPER_HASH_UNAVAILABLE' ('The expected managed helper could not be hashed: {0}' -f $_.Exception.Message)
|
|
129
|
-
} finally {
|
|
130
|
-
if ($null -ne $sha256) { $sha256.Dispose() }
|
|
131
|
-
if ($null -ne $stream) { $stream.Dispose() }
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function Get-ExpectedIdentity {
|
|
136
|
-
param(
|
|
137
|
-
[Parameter(Mandatory = $true)] [psobject] $Request,
|
|
138
|
-
[bool] $RequireExact = $false
|
|
139
|
-
)
|
|
140
|
-
$source = Get-IdentitySource -Request $Request
|
|
141
|
-
$pidValue = Get-RequestProperty -Request $source -Name 'pid' -Required $true
|
|
142
|
-
try {
|
|
143
|
-
$pid64 = [long] $pidValue
|
|
144
|
-
} catch {
|
|
145
|
-
Throw-ControlFailure 'REQUEST_INVALID' 'pid must be a positive integer.'
|
|
146
|
-
}
|
|
147
|
-
if ($pid64 -le 0 -or $pid64 -gt [int]::MaxValue -or $pidValue.ToString() -notmatch '^\d+$') {
|
|
148
|
-
Throw-ControlFailure 'REQUEST_INVALID' 'pid must be a positive integer.'
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
$ticksValue = Get-RequestProperty -Request $source -Name 'process_started_at_utc_ticks' -Required $RequireExact
|
|
152
|
-
if ($null -ne $ticksValue -and ($ticksValue -isnot [string] -or $ticksValue -notmatch '^\d+$' -or $ticksValue -eq '0')) {
|
|
153
|
-
Throw-ControlFailure 'REQUEST_INVALID' 'process_started_at_utc_ticks must be a positive decimal string.'
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
$hashValue = Get-RequestProperty -Request $source -Name 'helper_hash' -Required $RequireExact
|
|
157
|
-
if ($null -ne $hashValue -and ($hashValue -isnot [string] -or $hashValue -notmatch '^[a-fA-F0-9]{64}$')) {
|
|
158
|
-
Throw-ControlFailure 'REQUEST_INVALID' 'helper_hash must be a SHA-256 hex digest when supplied.'
|
|
159
|
-
}
|
|
160
|
-
return [pscustomobject]@{
|
|
161
|
-
pid = [int] $pid64
|
|
162
|
-
process_started_at_utc_ticks = $ticksValue
|
|
163
|
-
helper_hash = if ($null -eq $hashValue) { $null } else { $hashValue.ToLowerInvariant() }
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function Test-SamePath {
|
|
168
|
-
param([string] $Left, [string] $Right)
|
|
169
|
-
try {
|
|
170
|
-
$leftFull = [System.IO.Path]::GetFullPath($Left)
|
|
171
|
-
$rightFull = [System.IO.Path]::GetFullPath($Right)
|
|
172
|
-
return [string]::Equals($leftFull, $rightFull, [System.StringComparison]::OrdinalIgnoreCase)
|
|
173
|
-
} catch {
|
|
174
|
-
return $false
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function ConvertFrom-WindowsCommandLine {
|
|
179
|
-
param([Parameter(Mandatory = $true)] [string] $CommandLine)
|
|
180
|
-
$tokens = New-Object System.Collections.Generic.List[string]
|
|
181
|
-
$length = $CommandLine.Length
|
|
182
|
-
$index = 0
|
|
183
|
-
while ($index -lt $length) {
|
|
184
|
-
while ($index -lt $length -and [char]::IsWhiteSpace($CommandLine[$index])) { $index++ }
|
|
185
|
-
if ($index -ge $length) { break }
|
|
186
|
-
|
|
187
|
-
$value = New-Object System.Text.StringBuilder
|
|
188
|
-
$inQuotes = $false
|
|
189
|
-
while ($index -lt $length) {
|
|
190
|
-
$character = $CommandLine[$index]
|
|
191
|
-
if ($character -eq '\') {
|
|
192
|
-
$slashStart = $index
|
|
193
|
-
while ($index -lt $length -and $CommandLine[$index] -eq '\') { $index++ }
|
|
194
|
-
$slashCount = $index - $slashStart
|
|
195
|
-
if ($index -lt $length -and $CommandLine[$index] -eq '"') {
|
|
196
|
-
for ($slash = 0; $slash -lt [Math]::Floor($slashCount / 2); $slash++) { $value.Append('\') | Out-Null }
|
|
197
|
-
if ($slashCount % 2 -eq 0) { $inQuotes = -not $inQuotes } else { $value.Append('"') | Out-Null }
|
|
198
|
-
$index++
|
|
199
|
-
} else {
|
|
200
|
-
for ($slash = 0; $slash -lt $slashCount; $slash++) { $value.Append('\') | Out-Null }
|
|
201
|
-
}
|
|
202
|
-
continue
|
|
203
|
-
}
|
|
204
|
-
if ($character -eq '"') {
|
|
205
|
-
if ($inQuotes -and $index + 1 -lt $length -and $CommandLine[$index + 1] -eq '"') {
|
|
206
|
-
$value.Append('"') | Out-Null
|
|
207
|
-
$index += 2
|
|
208
|
-
} else {
|
|
209
|
-
$inQuotes = -not $inQuotes
|
|
210
|
-
$index++
|
|
211
|
-
}
|
|
212
|
-
continue
|
|
213
|
-
}
|
|
214
|
-
if (-not $inQuotes -and [char]::IsWhiteSpace($character)) { break }
|
|
215
|
-
$value.Append($character) | Out-Null
|
|
216
|
-
$index++
|
|
217
|
-
}
|
|
218
|
-
$tokens.Add($value.ToString())
|
|
219
|
-
while ($index -lt $length -and [char]::IsWhiteSpace($CommandLine[$index])) { $index++ }
|
|
220
|
-
}
|
|
221
|
-
return $tokens.ToArray()
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function Test-ExactManagedCommand {
|
|
225
|
-
param(
|
|
226
|
-
[Parameter(Mandatory = $true)] [string] $CommandLine,
|
|
227
|
-
[Parameter(Mandatory = $true)] [string] $HelperPath
|
|
228
|
-
)
|
|
229
|
-
$tokens = @(ConvertFrom-WindowsCommandLine -CommandLine $CommandLine)
|
|
230
|
-
# MainModule already proves token 0 is the canonical powershell.exe. Require
|
|
231
|
-
# the complete remaining argv so additional script arguments or alternate
|
|
232
|
-
# execution modes cannot hide behind one valid -File/-Mode pair.
|
|
233
|
-
if ($tokens.Count -ne 10) { return $false }
|
|
234
|
-
$expected = @('-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File')
|
|
235
|
-
for ($index = 0; $index -lt $expected.Count; $index++) {
|
|
236
|
-
if (-not [string]::Equals($tokens[$index + 1], $expected[$index], [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
|
237
|
-
}
|
|
238
|
-
if (-not (Test-SamePath -Left $tokens[7] -Right $HelperPath)) { return $false }
|
|
239
|
-
if (-not [string]::Equals($tokens[8], '-Mode', [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
|
240
|
-
return [string]::Equals($tokens[9], 'watch', [System.StringComparison]::OrdinalIgnoreCase)
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function Open-VerifiedManagedWatcher {
|
|
244
|
-
param(
|
|
245
|
-
[Parameter(Mandatory = $true)] [string] $HelperPath,
|
|
246
|
-
[Parameter(Mandatory = $true)] [pscustomobject] $Expected,
|
|
247
|
-
[bool] $AllowHelperDrift = $false
|
|
248
|
-
)
|
|
249
|
-
$helperHash = Get-HelperHash -HelperPath $HelperPath
|
|
250
|
-
if (-not $AllowHelperDrift -and $null -ne $Expected.helper_hash -and -not [string]::Equals($Expected.helper_hash, $helperHash, [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
251
|
-
Throw-ControlFailure 'HELPER_HASH_MISMATCH' 'The managed helper hash no longer matches the expected watcher identity.'
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
$process = $null
|
|
255
|
-
try {
|
|
256
|
-
$process = Get-Process -Id $Expected.pid -ErrorAction Stop
|
|
257
|
-
} catch {
|
|
258
|
-
Throw-ControlFailure 'PROCESS_NOT_FOUND' 'The expected watcher PID is not live.'
|
|
259
|
-
}
|
|
260
|
-
try {
|
|
261
|
-
# Force one native process handle open and keep it live through every
|
|
262
|
-
# identity check (and through Kill for stop). Windows does not recycle the
|
|
263
|
-
# PID while this process object holds the underlying kernel object.
|
|
264
|
-
$openedHandle = $process.Handle
|
|
265
|
-
if ($openedHandle -eq [IntPtr]::Zero) { throw 'process handle is unavailable' }
|
|
266
|
-
$actualTicks = $process.StartTime.ToUniversalTime().Ticks.ToString([System.Globalization.CultureInfo]::InvariantCulture)
|
|
267
|
-
} catch {
|
|
268
|
-
if ($null -ne $process) { $process.Dispose() }
|
|
269
|
-
Throw-ControlFailure 'PROCESS_METADATA_UNAVAILABLE' 'The expected watcher process identity metadata is unavailable.'
|
|
270
|
-
}
|
|
271
|
-
if ($null -ne $Expected.process_started_at_utc_ticks -and -not [string]::Equals($actualTicks, $Expected.process_started_at_utc_ticks, [System.StringComparison]::Ordinal)) {
|
|
272
|
-
$process.Dispose()
|
|
273
|
-
Throw-ControlFailure 'PROCESS_START_TIME_MISMATCH' 'The watcher PID belongs to a different process lifetime.'
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
try {
|
|
277
|
-
$executablePath = $process.MainModule.FileName
|
|
278
|
-
$commandLine = [DshCrewSupervisorControl.NativeProcess]::ReadCommandLine($openedHandle)
|
|
279
|
-
} catch {
|
|
280
|
-
$process.Dispose()
|
|
281
|
-
Throw-ControlFailure 'PROCESS_METADATA_UNAVAILABLE' 'The watcher process metadata could not be read.'
|
|
282
|
-
}
|
|
283
|
-
if ([string]::IsNullOrWhiteSpace($executablePath)) {
|
|
284
|
-
$process.Dispose()
|
|
285
|
-
Throw-ControlFailure 'PROCESS_METADATA_UNAVAILABLE' 'The watcher executable path is unavailable.'
|
|
286
|
-
}
|
|
287
|
-
$expectedPowerShellPath = Join-Path $PSHOME 'powershell.exe'
|
|
288
|
-
if (-not [string]::Equals(
|
|
289
|
-
[System.IO.Path]::GetFullPath($executablePath),
|
|
290
|
-
[System.IO.Path]::GetFullPath($expectedPowerShellPath),
|
|
291
|
-
[System.StringComparison]::OrdinalIgnoreCase
|
|
292
|
-
)) {
|
|
293
|
-
$process.Dispose()
|
|
294
|
-
Throw-ControlFailure 'PROCESS_EXECUTABLE_MISMATCH' 'The watcher PID is not powershell.exe.'
|
|
295
|
-
}
|
|
296
|
-
if ([string]::IsNullOrWhiteSpace($commandLine) -or -not (Test-ExactManagedCommand -CommandLine $commandLine -HelperPath $HelperPath)) {
|
|
297
|
-
$process.Dispose()
|
|
298
|
-
Throw-ControlFailure 'PROCESS_COMMAND_MISMATCH' 'The watcher command line does not name the exact managed helper in watch mode.'
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
return [pscustomobject]@{
|
|
302
|
-
Process = $process
|
|
303
|
-
Watcher = [pscustomobject]@{
|
|
304
|
-
pid = $Expected.pid
|
|
305
|
-
process_started_at_utc_ticks = $actualTicks
|
|
306
|
-
# A running PowerShell process has already loaded its script. During an
|
|
307
|
-
# upgrade the stable helper file is replaced before handoff, so its
|
|
308
|
-
# current disk hash is the target hash, not the old process's attested
|
|
309
|
-
# hash. Drift is allowed only with the full PID/start/hash identity and
|
|
310
|
-
# the exact managed command line; preserve that attested old hash here.
|
|
311
|
-
helper_hash = if ($AllowHelperDrift) { $Expected.helper_hash } else { $helperHash }
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function Get-ExactManagedWatcher {
|
|
317
|
-
param(
|
|
318
|
-
[Parameter(Mandatory = $true)] [string] $HelperPath,
|
|
319
|
-
[Parameter(Mandatory = $true)] [pscustomobject] $Expected,
|
|
320
|
-
[bool] $AllowHelperDrift = $false
|
|
321
|
-
)
|
|
322
|
-
$opened = Open-VerifiedManagedWatcher -HelperPath $HelperPath -Expected $Expected -AllowHelperDrift $AllowHelperDrift
|
|
323
|
-
try {
|
|
324
|
-
return $opened.Watcher
|
|
325
|
-
} finally {
|
|
326
|
-
$opened.Process.Dispose()
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function Start-ExactManagedWatcher {
|
|
331
|
-
param([Parameter(Mandatory = $true)] [string] $HelperPath)
|
|
332
|
-
$powerShellPath = Join-Path $PSHOME 'powershell.exe'
|
|
333
|
-
if (-not (Test-Path -LiteralPath $powerShellPath -PathType Leaf)) {
|
|
334
|
-
Throw-ControlFailure 'POWERSHELL_NOT_FOUND' 'powershell.exe is unavailable.'
|
|
335
|
-
}
|
|
336
|
-
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
|
|
337
|
-
$startInfo.FileName = $powerShellPath
|
|
338
|
-
$startInfo.Arguments = ('-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{0}" -Mode watch' -f $HelperPath)
|
|
339
|
-
# Shell execution prevents the long-lived watcher from inheriting this
|
|
340
|
-
# controller's JSON stdout/stderr pipe handles.
|
|
341
|
-
$startInfo.UseShellExecute = $true
|
|
342
|
-
$startInfo.CreateNoWindow = $true
|
|
343
|
-
$startInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
|
|
344
|
-
try {
|
|
345
|
-
$started = [System.Diagnostics.Process]::Start($startInfo)
|
|
346
|
-
if ($null -eq $started) { throw 'Process.Start returned no process.' }
|
|
347
|
-
$startedTicks = $started.StartTime.ToUniversalTime().Ticks.ToString([System.Globalization.CultureInfo]::InvariantCulture)
|
|
348
|
-
} catch {
|
|
349
|
-
Throw-ControlFailure 'PROCESS_START_FAILED' 'The managed watcher could not be started.'
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
$expected = [pscustomobject]@{
|
|
353
|
-
pid = [int] $started.Id
|
|
354
|
-
process_started_at_utc_ticks = $startedTicks
|
|
355
|
-
helper_hash = (Get-HelperHash -HelperPath $HelperPath)
|
|
356
|
-
}
|
|
357
|
-
$lastFailure = $null
|
|
358
|
-
Start-Sleep -Milliseconds 100
|
|
359
|
-
try {
|
|
360
|
-
return Get-ExactManagedWatcher -HelperPath $HelperPath -Expected $expected
|
|
361
|
-
} catch {
|
|
362
|
-
$lastFailure = $_.Exception
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
# The Process object identifies the exact child started above. Cleanup is
|
|
366
|
-
# single-process only; no child tree or unrelated PID is touched.
|
|
367
|
-
try {
|
|
368
|
-
if (-not $started.HasExited) { $started.Kill() }
|
|
369
|
-
} catch { }
|
|
370
|
-
if ($null -ne $lastFailure -and $lastFailure.Data.Contains('dsh_crew_control_code')) {
|
|
371
|
-
Throw-ControlFailure ([string] $lastFailure.Data['dsh_crew_control_code']) $lastFailure.Message
|
|
372
|
-
}
|
|
373
|
-
Throw-ControlFailure 'PROCESS_START_VERIFY_FAILED' 'The newly started managed watcher could not be verified.'
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function Stop-ExactManagedWatcher {
|
|
377
|
-
param(
|
|
378
|
-
[Parameter(Mandatory = $true)] [string] $HelperPath,
|
|
379
|
-
[Parameter(Mandatory = $true)] [pscustomobject] $Expected,
|
|
380
|
-
[bool] $AllowHelperDrift = $false
|
|
381
|
-
)
|
|
382
|
-
$opened = Open-VerifiedManagedWatcher -HelperPath $HelperPath -Expected $Expected -AllowHelperDrift $AllowHelperDrift
|
|
383
|
-
$watcher = $opened.Watcher
|
|
384
|
-
try {
|
|
385
|
-
$opened.Process.Kill()
|
|
386
|
-
if (-not $opened.Process.WaitForExit(5000)) {
|
|
387
|
-
Throw-ControlFailure 'PROCESS_STOP_TIMEOUT' 'The exact managed watcher did not exit before the timeout.'
|
|
388
|
-
}
|
|
389
|
-
} catch {
|
|
390
|
-
if ($_.Exception.Data.Contains('dsh_crew_control_code')) { throw }
|
|
391
|
-
Throw-ControlFailure 'PROCESS_STOP_FAILED' 'The exact managed watcher could not be stopped.'
|
|
392
|
-
} finally {
|
|
393
|
-
$opened.Process.Dispose()
|
|
394
|
-
}
|
|
395
|
-
return $watcher
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
$response = $null
|
|
399
|
-
$exitCode = 0
|
|
400
|
-
try {
|
|
401
|
-
$rawRequest = [Console]::In.ReadToEnd()
|
|
402
|
-
if ([string]::IsNullOrWhiteSpace($rawRequest)) {
|
|
403
|
-
Throw-ControlFailure 'REQUEST_INVALID' 'A JSON request is required on stdin.'
|
|
404
|
-
}
|
|
405
|
-
try {
|
|
406
|
-
$request = $rawRequest | ConvertFrom-Json -ErrorAction Stop
|
|
407
|
-
} catch {
|
|
408
|
-
Throw-ControlFailure 'REQUEST_JSON_INVALID' 'The stdin request is not valid JSON.'
|
|
409
|
-
}
|
|
410
|
-
if ($null -eq $request -or $request -is [System.Array] -or $request -is [string]) {
|
|
411
|
-
Throw-ControlFailure 'REQUEST_INVALID' 'The JSON request must be an object.'
|
|
412
|
-
}
|
|
413
|
-
$operationValue = Get-RequestProperty -Request $request -Name 'operation' -Required $true
|
|
414
|
-
if ($operationValue -isnot [string]) {
|
|
415
|
-
Throw-ControlFailure 'REQUEST_INVALID' 'operation must be a string.'
|
|
416
|
-
}
|
|
417
|
-
$operation = $operationValue.ToLowerInvariant()
|
|
418
|
-
if ($operation -notin @('inspect', 'start', 'stop')) {
|
|
419
|
-
Throw-ControlFailure 'OPERATION_UNSUPPORTED' 'operation must be inspect, start, or stop.'
|
|
420
|
-
}
|
|
421
|
-
$helperPath = Get-CanonicalHelperPath (Get-RequestProperty -Request $request -Name 'helper_path' -Required $true)
|
|
422
|
-
$allowHelperDriftValue = Get-RequestProperty -Request $request -Name 'allow_helper_drift'
|
|
423
|
-
if ($null -ne $allowHelperDriftValue -and $allowHelperDriftValue -isnot [bool]) {
|
|
424
|
-
Throw-ControlFailure 'REQUEST_INVALID' 'allow_helper_drift must be a boolean when supplied.'
|
|
425
|
-
}
|
|
426
|
-
$allowHelperDrift = $allowHelperDriftValue -eq $true
|
|
427
|
-
if ($operation -eq 'start' -and $allowHelperDrift) {
|
|
428
|
-
Throw-ControlFailure 'REQUEST_INVALID' 'allow_helper_drift is not valid for start.'
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
if ($operation -eq 'start') {
|
|
432
|
-
$requestedHash = Get-RequestProperty -Request $request -Name 'helper_hash' -Required $true
|
|
433
|
-
if ($requestedHash -isnot [string] -or $requestedHash -notmatch '^[a-fA-F0-9]{64}$') {
|
|
434
|
-
Throw-ControlFailure 'REQUEST_INVALID' 'helper_hash must be the expected SHA-256 digest for start.'
|
|
435
|
-
}
|
|
436
|
-
$actualHash = Get-HelperHash -HelperPath $helperPath
|
|
437
|
-
if (-not [string]::Equals($requestedHash, $actualHash, [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
438
|
-
Throw-ControlFailure 'HELPER_HASH_MISMATCH' 'Refusing to start a managed helper whose hash differs from the target.'
|
|
439
|
-
}
|
|
440
|
-
$watcher = Start-ExactManagedWatcher -HelperPath $helperPath
|
|
441
|
-
$response = [pscustomobject]@{ ok = $true; operation = 'start'; watcher = $watcher }
|
|
442
|
-
} else {
|
|
443
|
-
$expected = Get-ExpectedIdentity -Request $request -RequireExact ($operation -eq 'stop' -or $allowHelperDrift)
|
|
444
|
-
if ($operation -eq 'inspect') {
|
|
445
|
-
$watcher = Get-ExactManagedWatcher -HelperPath $helperPath -Expected $expected -AllowHelperDrift $allowHelperDrift
|
|
446
|
-
$response = [pscustomobject]@{ ok = $true; operation = 'inspect'; watcher = $watcher }
|
|
447
|
-
} else {
|
|
448
|
-
$watcher = Stop-ExactManagedWatcher -HelperPath $helperPath -Expected $expected -AllowHelperDrift $allowHelperDrift
|
|
449
|
-
$response = [pscustomobject]@{ ok = $true; operation = 'stop'; stopped = $true; watcher = $watcher }
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
} catch {
|
|
453
|
-
$exitCode = 1
|
|
454
|
-
$code = if ($_.Exception.Data.Contains('dsh_crew_control_code')) {
|
|
455
|
-
[string] $_.Exception.Data['dsh_crew_control_code']
|
|
456
|
-
} else {
|
|
457
|
-
'SUPERVISOR_CONTROL_FAILED'
|
|
458
|
-
}
|
|
459
|
-
$response = [pscustomobject]@{
|
|
460
|
-
ok = $false
|
|
461
|
-
code = $code
|
|
462
|
-
error = $_.Exception.Message
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
[Console]::Out.WriteLine(($response | ConvertTo-Json -Compress -Depth 6))
|
|
467
|
-
exit $exitCode
|
|
1
|
+
# DSH Crew Windows supervisor process control
|
|
2
|
+
#
|
|
3
|
+
# Reads one JSON request from stdin and writes exactly one JSON response to
|
|
4
|
+
# stdout. This helper deliberately controls one verified watcher PID at a
|
|
5
|
+
# time; it never uses process-tree termination.
|
|
6
|
+
[CmdletBinding()]
|
|
7
|
+
param()
|
|
8
|
+
|
|
9
|
+
Set-StrictMode -Version Latest
|
|
10
|
+
$ErrorActionPreference = 'Stop'
|
|
11
|
+
|
|
12
|
+
Add-Type -TypeDefinition @'
|
|
13
|
+
using System;
|
|
14
|
+
using System.Runtime.InteropServices;
|
|
15
|
+
|
|
16
|
+
namespace DshCrewSupervisorControl {
|
|
17
|
+
public static class NativeProcess {
|
|
18
|
+
private const int ProcessCommandLineInformation = 60;
|
|
19
|
+
|
|
20
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
21
|
+
private struct UNICODE_STRING {
|
|
22
|
+
public ushort Length;
|
|
23
|
+
public ushort MaximumLength;
|
|
24
|
+
public IntPtr Buffer;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
[DllImport("ntdll.dll")]
|
|
28
|
+
private static extern int NtQueryInformationProcess(
|
|
29
|
+
IntPtr process,
|
|
30
|
+
int informationClass,
|
|
31
|
+
IntPtr information,
|
|
32
|
+
int informationLength,
|
|
33
|
+
out int returnLength);
|
|
34
|
+
|
|
35
|
+
public static string ReadCommandLine(IntPtr process) {
|
|
36
|
+
int length;
|
|
37
|
+
NtQueryInformationProcess(process, ProcessCommandLineInformation, IntPtr.Zero, 0, out length);
|
|
38
|
+
if (length <= 0 || length > 1024 * 1024) {
|
|
39
|
+
throw new InvalidOperationException("Process command line length is unavailable or unsafe.");
|
|
40
|
+
}
|
|
41
|
+
IntPtr buffer = Marshal.AllocHGlobal(length);
|
|
42
|
+
try {
|
|
43
|
+
int returned;
|
|
44
|
+
int status = NtQueryInformationProcess(process, ProcessCommandLineInformation, buffer, length, out returned);
|
|
45
|
+
if (status != 0) throw new InvalidOperationException("Process command line query failed.");
|
|
46
|
+
UNICODE_STRING value = (UNICODE_STRING)Marshal.PtrToStructure(buffer, typeof(UNICODE_STRING));
|
|
47
|
+
if (value.Buffer == IntPtr.Zero || value.Length == 0) return String.Empty;
|
|
48
|
+
return Marshal.PtrToStringUni(value.Buffer, value.Length / 2);
|
|
49
|
+
} finally {
|
|
50
|
+
Marshal.FreeHGlobal(buffer);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
'@ | Out-Null
|
|
56
|
+
|
|
57
|
+
function Throw-ControlFailure {
|
|
58
|
+
param(
|
|
59
|
+
[Parameter(Mandatory = $true)] [string] $Code,
|
|
60
|
+
[Parameter(Mandatory = $true)] [string] $Message
|
|
61
|
+
)
|
|
62
|
+
$failure = New-Object System.InvalidOperationException($Message)
|
|
63
|
+
$failure.Data['dsh_crew_control_code'] = $Code
|
|
64
|
+
throw $failure
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function Get-RequestProperty {
|
|
68
|
+
param(
|
|
69
|
+
[Parameter(Mandatory = $true)] [psobject] $Request,
|
|
70
|
+
[Parameter(Mandatory = $true)] [string] $Name,
|
|
71
|
+
[bool] $Required = $false
|
|
72
|
+
)
|
|
73
|
+
$property = $Request.PSObject.Properties[$Name]
|
|
74
|
+
if ($null -eq $property) {
|
|
75
|
+
if ($Required) { Throw-ControlFailure 'REQUEST_INVALID' ("Missing required field: {0}." -f $Name) }
|
|
76
|
+
return $null
|
|
77
|
+
}
|
|
78
|
+
return $property.Value
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function Get-IdentitySource {
|
|
82
|
+
param([Parameter(Mandatory = $true)] [psobject] $Request)
|
|
83
|
+
$expected = Get-RequestProperty -Request $Request -Name 'expected'
|
|
84
|
+
if ($null -ne $expected) {
|
|
85
|
+
if ($expected -isnot [psobject] -or $expected -is [string]) {
|
|
86
|
+
Throw-ControlFailure 'REQUEST_INVALID' 'The expected field must be an object.'
|
|
87
|
+
}
|
|
88
|
+
return $expected
|
|
89
|
+
}
|
|
90
|
+
return $Request
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function Get-CanonicalHelperPath {
|
|
94
|
+
param([object] $Value)
|
|
95
|
+
if ($Value -isnot [string] -or [string]::IsNullOrWhiteSpace($Value) -or $Value.Length -gt 32768) {
|
|
96
|
+
Throw-ControlFailure 'HELPER_PATH_INVALID' 'helper_path must be a non-empty absolute path.'
|
|
97
|
+
}
|
|
98
|
+
if (-not [System.IO.Path]::IsPathRooted($Value) -or $Value.Contains('"')) {
|
|
99
|
+
Throw-ControlFailure 'HELPER_PATH_INVALID' 'helper_path must be a safe absolute path.'
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
$fullPath = [System.IO.Path]::GetFullPath($Value)
|
|
103
|
+
} catch {
|
|
104
|
+
Throw-ControlFailure 'HELPER_PATH_INVALID' 'helper_path could not be normalized.'
|
|
105
|
+
}
|
|
106
|
+
if (-not [string]::Equals([System.IO.Path]::GetFileName($fullPath), 'start-dsh-crew.ps1', [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
107
|
+
Throw-ControlFailure 'HELPER_PATH_INVALID' 'The managed helper must be named start-dsh-crew.ps1.'
|
|
108
|
+
}
|
|
109
|
+
if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) {
|
|
110
|
+
Throw-ControlFailure 'HELPER_PATH_NOT_FOUND' 'The expected managed helper does not exist.'
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
return (Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop).FullName
|
|
114
|
+
} catch {
|
|
115
|
+
Throw-ControlFailure 'HELPER_PATH_INVALID' 'The expected managed helper cannot be inspected.'
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function Get-HelperHash {
|
|
120
|
+
param([Parameter(Mandatory = $true)] [string] $HelperPath)
|
|
121
|
+
$stream = $null
|
|
122
|
+
$sha256 = $null
|
|
123
|
+
try {
|
|
124
|
+
$stream = [System.IO.File]::Open($HelperPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
|
|
125
|
+
$sha256 = [System.Security.Cryptography.SHA256]::Create()
|
|
126
|
+
return ([System.BitConverter]::ToString($sha256.ComputeHash($stream))).Replace('-', '').ToLowerInvariant()
|
|
127
|
+
} catch {
|
|
128
|
+
Throw-ControlFailure 'HELPER_HASH_UNAVAILABLE' ('The expected managed helper could not be hashed: {0}' -f $_.Exception.Message)
|
|
129
|
+
} finally {
|
|
130
|
+
if ($null -ne $sha256) { $sha256.Dispose() }
|
|
131
|
+
if ($null -ne $stream) { $stream.Dispose() }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function Get-ExpectedIdentity {
|
|
136
|
+
param(
|
|
137
|
+
[Parameter(Mandatory = $true)] [psobject] $Request,
|
|
138
|
+
[bool] $RequireExact = $false
|
|
139
|
+
)
|
|
140
|
+
$source = Get-IdentitySource -Request $Request
|
|
141
|
+
$pidValue = Get-RequestProperty -Request $source -Name 'pid' -Required $true
|
|
142
|
+
try {
|
|
143
|
+
$pid64 = [long] $pidValue
|
|
144
|
+
} catch {
|
|
145
|
+
Throw-ControlFailure 'REQUEST_INVALID' 'pid must be a positive integer.'
|
|
146
|
+
}
|
|
147
|
+
if ($pid64 -le 0 -or $pid64 -gt [int]::MaxValue -or $pidValue.ToString() -notmatch '^\d+$') {
|
|
148
|
+
Throw-ControlFailure 'REQUEST_INVALID' 'pid must be a positive integer.'
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
$ticksValue = Get-RequestProperty -Request $source -Name 'process_started_at_utc_ticks' -Required $RequireExact
|
|
152
|
+
if ($null -ne $ticksValue -and ($ticksValue -isnot [string] -or $ticksValue -notmatch '^\d+$' -or $ticksValue -eq '0')) {
|
|
153
|
+
Throw-ControlFailure 'REQUEST_INVALID' 'process_started_at_utc_ticks must be a positive decimal string.'
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
$hashValue = Get-RequestProperty -Request $source -Name 'helper_hash' -Required $RequireExact
|
|
157
|
+
if ($null -ne $hashValue -and ($hashValue -isnot [string] -or $hashValue -notmatch '^[a-fA-F0-9]{64}$')) {
|
|
158
|
+
Throw-ControlFailure 'REQUEST_INVALID' 'helper_hash must be a SHA-256 hex digest when supplied.'
|
|
159
|
+
}
|
|
160
|
+
return [pscustomobject]@{
|
|
161
|
+
pid = [int] $pid64
|
|
162
|
+
process_started_at_utc_ticks = $ticksValue
|
|
163
|
+
helper_hash = if ($null -eq $hashValue) { $null } else { $hashValue.ToLowerInvariant() }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function Test-SamePath {
|
|
168
|
+
param([string] $Left, [string] $Right)
|
|
169
|
+
try {
|
|
170
|
+
$leftFull = [System.IO.Path]::GetFullPath($Left)
|
|
171
|
+
$rightFull = [System.IO.Path]::GetFullPath($Right)
|
|
172
|
+
return [string]::Equals($leftFull, $rightFull, [System.StringComparison]::OrdinalIgnoreCase)
|
|
173
|
+
} catch {
|
|
174
|
+
return $false
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function ConvertFrom-WindowsCommandLine {
|
|
179
|
+
param([Parameter(Mandatory = $true)] [string] $CommandLine)
|
|
180
|
+
$tokens = New-Object System.Collections.Generic.List[string]
|
|
181
|
+
$length = $CommandLine.Length
|
|
182
|
+
$index = 0
|
|
183
|
+
while ($index -lt $length) {
|
|
184
|
+
while ($index -lt $length -and [char]::IsWhiteSpace($CommandLine[$index])) { $index++ }
|
|
185
|
+
if ($index -ge $length) { break }
|
|
186
|
+
|
|
187
|
+
$value = New-Object System.Text.StringBuilder
|
|
188
|
+
$inQuotes = $false
|
|
189
|
+
while ($index -lt $length) {
|
|
190
|
+
$character = $CommandLine[$index]
|
|
191
|
+
if ($character -eq '\') {
|
|
192
|
+
$slashStart = $index
|
|
193
|
+
while ($index -lt $length -and $CommandLine[$index] -eq '\') { $index++ }
|
|
194
|
+
$slashCount = $index - $slashStart
|
|
195
|
+
if ($index -lt $length -and $CommandLine[$index] -eq '"') {
|
|
196
|
+
for ($slash = 0; $slash -lt [Math]::Floor($slashCount / 2); $slash++) { $value.Append('\') | Out-Null }
|
|
197
|
+
if ($slashCount % 2 -eq 0) { $inQuotes = -not $inQuotes } else { $value.Append('"') | Out-Null }
|
|
198
|
+
$index++
|
|
199
|
+
} else {
|
|
200
|
+
for ($slash = 0; $slash -lt $slashCount; $slash++) { $value.Append('\') | Out-Null }
|
|
201
|
+
}
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
if ($character -eq '"') {
|
|
205
|
+
if ($inQuotes -and $index + 1 -lt $length -and $CommandLine[$index + 1] -eq '"') {
|
|
206
|
+
$value.Append('"') | Out-Null
|
|
207
|
+
$index += 2
|
|
208
|
+
} else {
|
|
209
|
+
$inQuotes = -not $inQuotes
|
|
210
|
+
$index++
|
|
211
|
+
}
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
if (-not $inQuotes -and [char]::IsWhiteSpace($character)) { break }
|
|
215
|
+
$value.Append($character) | Out-Null
|
|
216
|
+
$index++
|
|
217
|
+
}
|
|
218
|
+
$tokens.Add($value.ToString())
|
|
219
|
+
while ($index -lt $length -and [char]::IsWhiteSpace($CommandLine[$index])) { $index++ }
|
|
220
|
+
}
|
|
221
|
+
return $tokens.ToArray()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function Test-ExactManagedCommand {
|
|
225
|
+
param(
|
|
226
|
+
[Parameter(Mandatory = $true)] [string] $CommandLine,
|
|
227
|
+
[Parameter(Mandatory = $true)] [string] $HelperPath
|
|
228
|
+
)
|
|
229
|
+
$tokens = @(ConvertFrom-WindowsCommandLine -CommandLine $CommandLine)
|
|
230
|
+
# MainModule already proves token 0 is the canonical powershell.exe. Require
|
|
231
|
+
# the complete remaining argv so additional script arguments or alternate
|
|
232
|
+
# execution modes cannot hide behind one valid -File/-Mode pair.
|
|
233
|
+
if ($tokens.Count -ne 10) { return $false }
|
|
234
|
+
$expected = @('-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File')
|
|
235
|
+
for ($index = 0; $index -lt $expected.Count; $index++) {
|
|
236
|
+
if (-not [string]::Equals($tokens[$index + 1], $expected[$index], [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
|
237
|
+
}
|
|
238
|
+
if (-not (Test-SamePath -Left $tokens[7] -Right $HelperPath)) { return $false }
|
|
239
|
+
if (-not [string]::Equals($tokens[8], '-Mode', [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
|
240
|
+
return [string]::Equals($tokens[9], 'watch', [System.StringComparison]::OrdinalIgnoreCase)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function Open-VerifiedManagedWatcher {
|
|
244
|
+
param(
|
|
245
|
+
[Parameter(Mandatory = $true)] [string] $HelperPath,
|
|
246
|
+
[Parameter(Mandatory = $true)] [pscustomobject] $Expected,
|
|
247
|
+
[bool] $AllowHelperDrift = $false
|
|
248
|
+
)
|
|
249
|
+
$helperHash = Get-HelperHash -HelperPath $HelperPath
|
|
250
|
+
if (-not $AllowHelperDrift -and $null -ne $Expected.helper_hash -and -not [string]::Equals($Expected.helper_hash, $helperHash, [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
251
|
+
Throw-ControlFailure 'HELPER_HASH_MISMATCH' 'The managed helper hash no longer matches the expected watcher identity.'
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
$process = $null
|
|
255
|
+
try {
|
|
256
|
+
$process = Get-Process -Id $Expected.pid -ErrorAction Stop
|
|
257
|
+
} catch {
|
|
258
|
+
Throw-ControlFailure 'PROCESS_NOT_FOUND' 'The expected watcher PID is not live.'
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
# Force one native process handle open and keep it live through every
|
|
262
|
+
# identity check (and through Kill for stop). Windows does not recycle the
|
|
263
|
+
# PID while this process object holds the underlying kernel object.
|
|
264
|
+
$openedHandle = $process.Handle
|
|
265
|
+
if ($openedHandle -eq [IntPtr]::Zero) { throw 'process handle is unavailable' }
|
|
266
|
+
$actualTicks = $process.StartTime.ToUniversalTime().Ticks.ToString([System.Globalization.CultureInfo]::InvariantCulture)
|
|
267
|
+
} catch {
|
|
268
|
+
if ($null -ne $process) { $process.Dispose() }
|
|
269
|
+
Throw-ControlFailure 'PROCESS_METADATA_UNAVAILABLE' 'The expected watcher process identity metadata is unavailable.'
|
|
270
|
+
}
|
|
271
|
+
if ($null -ne $Expected.process_started_at_utc_ticks -and -not [string]::Equals($actualTicks, $Expected.process_started_at_utc_ticks, [System.StringComparison]::Ordinal)) {
|
|
272
|
+
$process.Dispose()
|
|
273
|
+
Throw-ControlFailure 'PROCESS_START_TIME_MISMATCH' 'The watcher PID belongs to a different process lifetime.'
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
$executablePath = $process.MainModule.FileName
|
|
278
|
+
$commandLine = [DshCrewSupervisorControl.NativeProcess]::ReadCommandLine($openedHandle)
|
|
279
|
+
} catch {
|
|
280
|
+
$process.Dispose()
|
|
281
|
+
Throw-ControlFailure 'PROCESS_METADATA_UNAVAILABLE' 'The watcher process metadata could not be read.'
|
|
282
|
+
}
|
|
283
|
+
if ([string]::IsNullOrWhiteSpace($executablePath)) {
|
|
284
|
+
$process.Dispose()
|
|
285
|
+
Throw-ControlFailure 'PROCESS_METADATA_UNAVAILABLE' 'The watcher executable path is unavailable.'
|
|
286
|
+
}
|
|
287
|
+
$expectedPowerShellPath = Join-Path $PSHOME 'powershell.exe'
|
|
288
|
+
if (-not [string]::Equals(
|
|
289
|
+
[System.IO.Path]::GetFullPath($executablePath),
|
|
290
|
+
[System.IO.Path]::GetFullPath($expectedPowerShellPath),
|
|
291
|
+
[System.StringComparison]::OrdinalIgnoreCase
|
|
292
|
+
)) {
|
|
293
|
+
$process.Dispose()
|
|
294
|
+
Throw-ControlFailure 'PROCESS_EXECUTABLE_MISMATCH' 'The watcher PID is not powershell.exe.'
|
|
295
|
+
}
|
|
296
|
+
if ([string]::IsNullOrWhiteSpace($commandLine) -or -not (Test-ExactManagedCommand -CommandLine $commandLine -HelperPath $HelperPath)) {
|
|
297
|
+
$process.Dispose()
|
|
298
|
+
Throw-ControlFailure 'PROCESS_COMMAND_MISMATCH' 'The watcher command line does not name the exact managed helper in watch mode.'
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return [pscustomobject]@{
|
|
302
|
+
Process = $process
|
|
303
|
+
Watcher = [pscustomobject]@{
|
|
304
|
+
pid = $Expected.pid
|
|
305
|
+
process_started_at_utc_ticks = $actualTicks
|
|
306
|
+
# A running PowerShell process has already loaded its script. During an
|
|
307
|
+
# upgrade the stable helper file is replaced before handoff, so its
|
|
308
|
+
# current disk hash is the target hash, not the old process's attested
|
|
309
|
+
# hash. Drift is allowed only with the full PID/start/hash identity and
|
|
310
|
+
# the exact managed command line; preserve that attested old hash here.
|
|
311
|
+
helper_hash = if ($AllowHelperDrift) { $Expected.helper_hash } else { $helperHash }
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function Get-ExactManagedWatcher {
|
|
317
|
+
param(
|
|
318
|
+
[Parameter(Mandatory = $true)] [string] $HelperPath,
|
|
319
|
+
[Parameter(Mandatory = $true)] [pscustomobject] $Expected,
|
|
320
|
+
[bool] $AllowHelperDrift = $false
|
|
321
|
+
)
|
|
322
|
+
$opened = Open-VerifiedManagedWatcher -HelperPath $HelperPath -Expected $Expected -AllowHelperDrift $AllowHelperDrift
|
|
323
|
+
try {
|
|
324
|
+
return $opened.Watcher
|
|
325
|
+
} finally {
|
|
326
|
+
$opened.Process.Dispose()
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function Start-ExactManagedWatcher {
|
|
331
|
+
param([Parameter(Mandatory = $true)] [string] $HelperPath)
|
|
332
|
+
$powerShellPath = Join-Path $PSHOME 'powershell.exe'
|
|
333
|
+
if (-not (Test-Path -LiteralPath $powerShellPath -PathType Leaf)) {
|
|
334
|
+
Throw-ControlFailure 'POWERSHELL_NOT_FOUND' 'powershell.exe is unavailable.'
|
|
335
|
+
}
|
|
336
|
+
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
|
|
337
|
+
$startInfo.FileName = $powerShellPath
|
|
338
|
+
$startInfo.Arguments = ('-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{0}" -Mode watch' -f $HelperPath)
|
|
339
|
+
# Shell execution prevents the long-lived watcher from inheriting this
|
|
340
|
+
# controller's JSON stdout/stderr pipe handles.
|
|
341
|
+
$startInfo.UseShellExecute = $true
|
|
342
|
+
$startInfo.CreateNoWindow = $true
|
|
343
|
+
$startInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
|
|
344
|
+
try {
|
|
345
|
+
$started = [System.Diagnostics.Process]::Start($startInfo)
|
|
346
|
+
if ($null -eq $started) { throw 'Process.Start returned no process.' }
|
|
347
|
+
$startedTicks = $started.StartTime.ToUniversalTime().Ticks.ToString([System.Globalization.CultureInfo]::InvariantCulture)
|
|
348
|
+
} catch {
|
|
349
|
+
Throw-ControlFailure 'PROCESS_START_FAILED' 'The managed watcher could not be started.'
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
$expected = [pscustomobject]@{
|
|
353
|
+
pid = [int] $started.Id
|
|
354
|
+
process_started_at_utc_ticks = $startedTicks
|
|
355
|
+
helper_hash = (Get-HelperHash -HelperPath $HelperPath)
|
|
356
|
+
}
|
|
357
|
+
$lastFailure = $null
|
|
358
|
+
Start-Sleep -Milliseconds 100
|
|
359
|
+
try {
|
|
360
|
+
return Get-ExactManagedWatcher -HelperPath $HelperPath -Expected $expected
|
|
361
|
+
} catch {
|
|
362
|
+
$lastFailure = $_.Exception
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
# The Process object identifies the exact child started above. Cleanup is
|
|
366
|
+
# single-process only; no child tree or unrelated PID is touched.
|
|
367
|
+
try {
|
|
368
|
+
if (-not $started.HasExited) { $started.Kill() }
|
|
369
|
+
} catch { }
|
|
370
|
+
if ($null -ne $lastFailure -and $lastFailure.Data.Contains('dsh_crew_control_code')) {
|
|
371
|
+
Throw-ControlFailure ([string] $lastFailure.Data['dsh_crew_control_code']) $lastFailure.Message
|
|
372
|
+
}
|
|
373
|
+
Throw-ControlFailure 'PROCESS_START_VERIFY_FAILED' 'The newly started managed watcher could not be verified.'
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function Stop-ExactManagedWatcher {
|
|
377
|
+
param(
|
|
378
|
+
[Parameter(Mandatory = $true)] [string] $HelperPath,
|
|
379
|
+
[Parameter(Mandatory = $true)] [pscustomobject] $Expected,
|
|
380
|
+
[bool] $AllowHelperDrift = $false
|
|
381
|
+
)
|
|
382
|
+
$opened = Open-VerifiedManagedWatcher -HelperPath $HelperPath -Expected $Expected -AllowHelperDrift $AllowHelperDrift
|
|
383
|
+
$watcher = $opened.Watcher
|
|
384
|
+
try {
|
|
385
|
+
$opened.Process.Kill()
|
|
386
|
+
if (-not $opened.Process.WaitForExit(5000)) {
|
|
387
|
+
Throw-ControlFailure 'PROCESS_STOP_TIMEOUT' 'The exact managed watcher did not exit before the timeout.'
|
|
388
|
+
}
|
|
389
|
+
} catch {
|
|
390
|
+
if ($_.Exception.Data.Contains('dsh_crew_control_code')) { throw }
|
|
391
|
+
Throw-ControlFailure 'PROCESS_STOP_FAILED' 'The exact managed watcher could not be stopped.'
|
|
392
|
+
} finally {
|
|
393
|
+
$opened.Process.Dispose()
|
|
394
|
+
}
|
|
395
|
+
return $watcher
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
$response = $null
|
|
399
|
+
$exitCode = 0
|
|
400
|
+
try {
|
|
401
|
+
$rawRequest = [Console]::In.ReadToEnd()
|
|
402
|
+
if ([string]::IsNullOrWhiteSpace($rawRequest)) {
|
|
403
|
+
Throw-ControlFailure 'REQUEST_INVALID' 'A JSON request is required on stdin.'
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
$request = $rawRequest | ConvertFrom-Json -ErrorAction Stop
|
|
407
|
+
} catch {
|
|
408
|
+
Throw-ControlFailure 'REQUEST_JSON_INVALID' 'The stdin request is not valid JSON.'
|
|
409
|
+
}
|
|
410
|
+
if ($null -eq $request -or $request -is [System.Array] -or $request -is [string]) {
|
|
411
|
+
Throw-ControlFailure 'REQUEST_INVALID' 'The JSON request must be an object.'
|
|
412
|
+
}
|
|
413
|
+
$operationValue = Get-RequestProperty -Request $request -Name 'operation' -Required $true
|
|
414
|
+
if ($operationValue -isnot [string]) {
|
|
415
|
+
Throw-ControlFailure 'REQUEST_INVALID' 'operation must be a string.'
|
|
416
|
+
}
|
|
417
|
+
$operation = $operationValue.ToLowerInvariant()
|
|
418
|
+
if ($operation -notin @('inspect', 'start', 'stop')) {
|
|
419
|
+
Throw-ControlFailure 'OPERATION_UNSUPPORTED' 'operation must be inspect, start, or stop.'
|
|
420
|
+
}
|
|
421
|
+
$helperPath = Get-CanonicalHelperPath (Get-RequestProperty -Request $request -Name 'helper_path' -Required $true)
|
|
422
|
+
$allowHelperDriftValue = Get-RequestProperty -Request $request -Name 'allow_helper_drift'
|
|
423
|
+
if ($null -ne $allowHelperDriftValue -and $allowHelperDriftValue -isnot [bool]) {
|
|
424
|
+
Throw-ControlFailure 'REQUEST_INVALID' 'allow_helper_drift must be a boolean when supplied.'
|
|
425
|
+
}
|
|
426
|
+
$allowHelperDrift = $allowHelperDriftValue -eq $true
|
|
427
|
+
if ($operation -eq 'start' -and $allowHelperDrift) {
|
|
428
|
+
Throw-ControlFailure 'REQUEST_INVALID' 'allow_helper_drift is not valid for start.'
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if ($operation -eq 'start') {
|
|
432
|
+
$requestedHash = Get-RequestProperty -Request $request -Name 'helper_hash' -Required $true
|
|
433
|
+
if ($requestedHash -isnot [string] -or $requestedHash -notmatch '^[a-fA-F0-9]{64}$') {
|
|
434
|
+
Throw-ControlFailure 'REQUEST_INVALID' 'helper_hash must be the expected SHA-256 digest for start.'
|
|
435
|
+
}
|
|
436
|
+
$actualHash = Get-HelperHash -HelperPath $helperPath
|
|
437
|
+
if (-not [string]::Equals($requestedHash, $actualHash, [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
438
|
+
Throw-ControlFailure 'HELPER_HASH_MISMATCH' 'Refusing to start a managed helper whose hash differs from the target.'
|
|
439
|
+
}
|
|
440
|
+
$watcher = Start-ExactManagedWatcher -HelperPath $helperPath
|
|
441
|
+
$response = [pscustomobject]@{ ok = $true; operation = 'start'; watcher = $watcher }
|
|
442
|
+
} else {
|
|
443
|
+
$expected = Get-ExpectedIdentity -Request $request -RequireExact ($operation -eq 'stop' -or $allowHelperDrift)
|
|
444
|
+
if ($operation -eq 'inspect') {
|
|
445
|
+
$watcher = Get-ExactManagedWatcher -HelperPath $helperPath -Expected $expected -AllowHelperDrift $allowHelperDrift
|
|
446
|
+
$response = [pscustomobject]@{ ok = $true; operation = 'inspect'; watcher = $watcher }
|
|
447
|
+
} else {
|
|
448
|
+
$watcher = Stop-ExactManagedWatcher -HelperPath $helperPath -Expected $expected -AllowHelperDrift $allowHelperDrift
|
|
449
|
+
$response = [pscustomobject]@{ ok = $true; operation = 'stop'; stopped = $true; watcher = $watcher }
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
} catch {
|
|
453
|
+
$exitCode = 1
|
|
454
|
+
$code = if ($_.Exception.Data.Contains('dsh_crew_control_code')) {
|
|
455
|
+
[string] $_.Exception.Data['dsh_crew_control_code']
|
|
456
|
+
} else {
|
|
457
|
+
'SUPERVISOR_CONTROL_FAILED'
|
|
458
|
+
}
|
|
459
|
+
$response = [pscustomobject]@{
|
|
460
|
+
ok = $false
|
|
461
|
+
code = $code
|
|
462
|
+
error = $_.Exception.Message
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
[Console]::Out.WriteLine(($response | ConvertTo-Json -Compress -Depth 6))
|
|
467
|
+
exit $exitCode
|