@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.
Files changed (151) hide show
  1. package/.claude-plugin/marketplace.json +16 -16
  2. package/.claude-plugin/plugin.json +14 -14
  3. package/LICENSE +21 -21
  4. package/README.de.md +359 -359
  5. package/README.es.md +359 -359
  6. package/README.fr.md +359 -359
  7. package/README.hi.md +359 -359
  8. package/README.id.md +359 -359
  9. package/README.ja.md +359 -359
  10. package/README.ko.md +359 -359
  11. package/README.md +126 -126
  12. package/README.pt.md +359 -359
  13. package/README.ru.md +359 -359
  14. package/README.th.md +359 -359
  15. package/README.tr.md +359 -359
  16. package/README.vi.md +359 -359
  17. package/README.zh-TW.md +359 -359
  18. package/README.zh.md +116 -116
  19. package/agents/ds-flash.md +24 -24
  20. package/agents/ds-pro.md +24 -24
  21. package/agents/ds-reviewer.md +23 -23
  22. package/agents/ds-worker.md +23 -23
  23. package/bin/dsh-crew.mjs +16 -16
  24. package/codex/agents/ds-flash.toml +34 -34
  25. package/codex/agents/ds-pro.toml +34 -34
  26. package/codex/agents/ds-reviewer.toml +32 -32
  27. package/codex/agents/ds-worker.toml +32 -32
  28. package/codex/prompts/dsh-config.md +19 -19
  29. package/codex/prompts/dsh-status.md +5 -5
  30. package/commands/config.md +23 -23
  31. package/commands/off.md +5 -5
  32. package/commands/on.md +5 -5
  33. package/commands/status.md +9 -9
  34. package/cordis.patch.yml +4 -4
  35. package/docs/gpt-relay-extension.md +103 -103
  36. package/docs/installation.md +138 -138
  37. package/docs/job-contracts.md +103 -103
  38. package/docs/readiness-matrix.md +85 -85
  39. package/docs/ui-surfaces.md +107 -107
  40. package/official-web-bridge/cordis.patch.yml +4 -4
  41. package/official-web-bridge/entry.mjs +1 -1
  42. package/official-web-bridge/overlay-entry.mjs +59 -59
  43. package/official-web-bridge/package.json +25 -25
  44. package/package.json +3 -2
  45. package/scripts/build-client.mjs +49 -49
  46. package/scripts/live-crew-smoke.mjs +39 -39
  47. package/scripts/live-policy-matrix.mjs +177 -177
  48. package/scripts/policy-probe.mjs +101 -101
  49. package/scripts/remove-legacy-official-bridge.ps1 +89 -89
  50. package/scripts/setup.mjs +393 -393
  51. package/scripts/smoke-real.mjs +110 -110
  52. package/scripts/smoke.mjs +78 -78
  53. package/scripts/verify-crew-ui-polish.mjs +145 -145
  54. package/scripts/verify-history-ui.mjs +97 -97
  55. package/scripts/verify-installer-fix.mjs +26 -26
  56. package/scripts/verify-npm-install.mjs +311 -311
  57. package/scripts/verify-official-bridge-e2e.mjs +192 -192
  58. package/src/adaptive-routing.mjs +260 -260
  59. package/src/client/activation-summary.tsx +64 -64
  60. package/src/client/collapsible-sections.mjs +55 -55
  61. package/src/client/history-panel.tsx +108 -108
  62. package/src/client/host-readiness.mjs +71 -71
  63. package/src/client/index.tsx +1711 -1711
  64. package/src/client/model-callability-view.mjs +17 -17
  65. package/src/client/panel-chrome.tsx +40 -40
  66. package/src/client/quick-entry.tsx +10 -10
  67. package/src/client/quick-panel.tsx +275 -275
  68. package/src/client/readiness-envelope.mjs +34 -34
  69. package/src/client/surface-detection.mjs +43 -43
  70. package/src/config-readiness.mjs +226 -226
  71. package/src/credential-reference.mjs +38 -38
  72. package/src/delivery.mjs +205 -205
  73. package/src/dsh-cli-runtime.mjs +1021 -1021
  74. package/src/dsh-cohort.mjs +20 -20
  75. package/src/extension-contract.mjs +104 -104
  76. package/src/failure-classification.mjs +201 -201
  77. package/src/history/admission-gate.mjs +67 -67
  78. package/src/history/archive-store.mjs +272 -272
  79. package/src/history/cleanup-plan.mjs +89 -89
  80. package/src/history/http.mjs +32 -32
  81. package/src/history/operation.mjs +86 -86
  82. package/src/history/runner-detach.mjs +34 -34
  83. package/src/history/runner.mjs +52 -52
  84. package/src/history/runtime.mjs +36 -36
  85. package/src/history/service.mjs +177 -177
  86. package/src/history/state.mjs +27 -27
  87. package/src/hub/entry.mjs +104 -104
  88. package/src/hub/index.mjs +2694 -2694
  89. package/src/hub-client.mjs +154 -154
  90. package/src/hub-compatibility.mjs +42 -42
  91. package/src/i18n.mjs +19 -19
  92. package/src/information-flow.mjs +67 -67
  93. package/src/install/cli.mjs +28 -28
  94. package/src/install/install-legacy.mjs +711 -711
  95. package/src/install/install.mjs +483 -483
  96. package/src/install/npx-lifecycle.mjs +3456 -3456
  97. package/src/install/official-frontend-assets.mjs +78 -78
  98. package/src/install/official-web.mjs +95 -95
  99. package/src/install/payload-content.mjs +88 -88
  100. package/src/install/windows-startup.mjs +236 -236
  101. package/src/install/windows-supervisor-adapter.mjs +443 -443
  102. package/src/install/windows-supervisor-lifecycle.mjs +782 -782
  103. package/src/install/zcode.mjs +397 -397
  104. package/src/job-contracts.mjs +255 -255
  105. package/src/local-request-guard.mjs +60 -60
  106. package/src/mcp-runtime.mjs +340 -340
  107. package/src/model-callability-contract.mjs +79 -79
  108. package/src/model-catalog.mjs +180 -180
  109. package/src/model-routing.mjs +586 -586
  110. package/src/model-schedule.mjs +207 -207
  111. package/src/official-web-bridge.mjs +447 -447
  112. package/src/policy.mjs +235 -235
  113. package/src/provider-delete-adapters.mjs +1934 -1934
  114. package/src/provider-health.mjs +130 -130
  115. package/src/provider-inventory.mjs +182 -182
  116. package/src/provider-layer-migration-adapters.mjs +759 -759
  117. package/src/provider-layer-migration.mjs +198 -198
  118. package/src/provider-lifecycle-state.mjs +103 -103
  119. package/src/provider-lifecycle.mjs +252 -252
  120. package/src/provider-profile-store.mjs +390 -390
  121. package/src/provider-settings-store.mjs +633 -633
  122. package/src/provider-store-lock.mjs +67 -67
  123. package/src/readiness-matrix.mjs +181 -181
  124. package/src/removable-waiter.mjs +29 -29
  125. package/src/role-profiles.mjs +107 -107
  126. package/src/runtime-controls.mjs +84 -84
  127. package/src/runtime-identity-contract.mjs +34 -34
  128. package/src/runtime-identity.mjs +235 -235
  129. package/src/runtime-readiness-snapshot.mjs +285 -285
  130. package/src/server.mjs +581 -581
  131. package/src/session-origins.mjs +60 -60
  132. package/src/standalone-sdk.mjs +23 -23
  133. package/src/status-shard.mjs +63 -63
  134. package/src/structured-error-code.mjs +38 -38
  135. package/src/supervisor/restart-request.mjs +256 -256
  136. package/src/workflow-runtime.mjs +739 -739
  137. package/src/workflow.mjs +155 -155
  138. package/src/workspace-audit.mjs +231 -231
  139. package/src/workspace-context.mjs +146 -146
  140. package/src/workspace-isolation.mjs +463 -455
  141. package/src/workspace-readiness.mjs +32 -32
  142. package/statusline/statusline.sh +14 -14
  143. package/statusline/worker-segment.sh +35 -35
  144. package/windows/start-dsh-crew.cmd +57 -57
  145. package/windows/start-dsh-crew.ps1 +1302 -1302
  146. package/windows/supervisor-control.ps1 +467 -467
  147. package/worker.cordis.yml +67 -67
  148. package/zcode/agents/ds-reviewer.md +31 -31
  149. package/zcode/agents/ds-worker.md +31 -31
  150. package/zcode/commands/dsh-config.md +17 -17
  151. 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