patchwarden 0.4.0 → 0.6.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 (139) hide show
  1. package/PatchWarden.cmd +51 -0
  2. package/README.en.md +1022 -0
  3. package/README.md +897 -358
  4. package/dist/assessments/agentAssessor.d.ts +15 -0
  5. package/dist/assessments/agentAssessor.js +293 -0
  6. package/dist/assessments/assessmentStore.d.ts +133 -0
  7. package/dist/assessments/assessmentStore.js +238 -0
  8. package/dist/assessments/confirmCli.d.ts +9 -0
  9. package/dist/assessments/confirmCli.js +26 -0
  10. package/dist/config.d.ts +22 -1
  11. package/dist/config.js +156 -3
  12. package/dist/direct/directAudit.d.ts +23 -0
  13. package/dist/direct/directAudit.js +309 -0
  14. package/dist/direct/directGuards.d.ts +20 -0
  15. package/dist/direct/directGuards.js +137 -0
  16. package/dist/direct/directPatch.d.ts +17 -0
  17. package/dist/direct/directPatch.js +113 -0
  18. package/dist/direct/directSessionStore.d.ts +63 -0
  19. package/dist/direct/directSessionStore.js +192 -0
  20. package/dist/direct/directVerification.d.ts +12 -0
  21. package/dist/direct/directVerification.js +96 -0
  22. package/dist/doctor.js +73 -5
  23. package/dist/runner/agentInvocation.d.ts +23 -0
  24. package/dist/runner/agentInvocation.js +106 -0
  25. package/dist/runner/changeCapture.d.ts +29 -0
  26. package/dist/runner/changeCapture.js +107 -5
  27. package/dist/runner/runTask.js +70 -44
  28. package/dist/runner/simpleProcess.d.ts +21 -0
  29. package/dist/runner/simpleProcess.js +206 -0
  30. package/dist/runner/watch.js +17 -2
  31. package/dist/security/commandGuard.d.ts +2 -1
  32. package/dist/security/commandGuard.js +25 -3
  33. package/dist/security/riskEngine.d.ts +27 -0
  34. package/dist/security/riskEngine.js +118 -0
  35. package/dist/security/runtimeGuard.d.ts +6 -0
  36. package/dist/security/runtimeGuard.js +28 -0
  37. package/dist/smoke-test.js +1353 -7
  38. package/dist/tools/applyPatch.d.ts +16 -0
  39. package/dist/tools/applyPatch.js +41 -0
  40. package/dist/tools/auditSession.d.ts +5 -0
  41. package/dist/tools/auditSession.js +12 -0
  42. package/dist/tools/auditTask.d.ts +7 -0
  43. package/dist/tools/auditTask.js +94 -2
  44. package/dist/tools/createDirectSession.d.ts +14 -0
  45. package/dist/tools/createDirectSession.js +54 -0
  46. package/dist/tools/createTask.d.ts +48 -1
  47. package/dist/tools/createTask.js +298 -47
  48. package/dist/tools/finalizeDirectSession.d.ts +19 -0
  49. package/dist/tools/finalizeDirectSession.js +84 -0
  50. package/dist/tools/getTaskSummary.d.ts +47 -0
  51. package/dist/tools/getTaskSummary.js +64 -1
  52. package/dist/tools/healthCheck.d.ts +22 -12
  53. package/dist/tools/healthCheck.js +23 -6
  54. package/dist/tools/readWorkspaceFile.d.ts +7 -1
  55. package/dist/tools/readWorkspaceFile.js +48 -2
  56. package/dist/tools/registry.js +217 -19
  57. package/dist/tools/runVerification.d.ts +16 -0
  58. package/dist/tools/runVerification.js +32 -0
  59. package/dist/tools/savePlan.d.ts +1 -0
  60. package/dist/tools/savePlan.js +38 -7
  61. package/dist/tools/searchWorkspace.d.ts +19 -0
  62. package/dist/tools/searchWorkspace.js +205 -0
  63. package/dist/tools/taskTemplates.js +2 -1
  64. package/dist/tools/toolCatalog.d.ts +3 -2
  65. package/dist/tools/toolCatalog.js +32 -5
  66. package/dist/tools/waitForTask.d.ts +2 -2
  67. package/dist/tools/waitForTask.js +1 -1
  68. package/dist/version.d.ts +2 -2
  69. package/dist/version.js +2 -2
  70. package/docs/chatgpt-usage.md +101 -0
  71. package/docs/release-checklist.md +14 -0
  72. package/docs/release-v0.6.0.md +71 -0
  73. package/examples/config.example.json +5 -0
  74. package/examples/openai-tunnel/README.md +11 -4
  75. package/examples/openai-tunnel/chatgpt-test-prompt.md +18 -14
  76. package/examples/openai-tunnel/tunnel-client.example.yaml +8 -0
  77. package/package.json +12 -8
  78. package/scripts/brand-check.js +58 -12
  79. package/scripts/control-smoke.js +206 -0
  80. package/scripts/launchers/Check-PatchWarden-Health.cmd +6 -0
  81. package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
  82. package/scripts/launchers/Restart-PatchWarden.cmd +6 -0
  83. package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +7 -0
  84. package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +7 -0
  85. package/scripts/lifecycle-smoke.js +66 -6
  86. package/scripts/manage-patchwarden.ps1 +639 -0
  87. package/scripts/mcp-manifest-check.js +100 -58
  88. package/scripts/mcp-smoke.js +157 -2
  89. package/scripts/pack-clean.js +3 -4
  90. package/scripts/package-manifest-check.js +78 -0
  91. package/scripts/patchwarden-mcp-direct.cmd +7 -0
  92. package/scripts/patchwarden-mcp-stdio.cmd +1 -1
  93. package/scripts/restart-patchwarden.ps1 +5 -4
  94. package/scripts/start-patchwarden-tunnel.ps1 +261 -30
  95. package/scripts/tunnel-supervisor-smoke.js +36 -4
  96. package/scripts/watcher-supervisor-smoke.js +10 -6
  97. package/src/assessments/agentAssessor.ts +324 -0
  98. package/src/assessments/assessmentStore.ts +426 -0
  99. package/src/assessments/confirmCli.ts +29 -0
  100. package/src/config.ts +178 -4
  101. package/src/direct/directAudit.ts +400 -0
  102. package/src/direct/directGuards.ts +279 -0
  103. package/src/direct/directPatch.ts +258 -0
  104. package/src/direct/directSessionStore.ts +345 -0
  105. package/src/direct/directVerification.ts +138 -0
  106. package/src/doctor.ts +103 -7
  107. package/src/runner/agentInvocation.ts +125 -0
  108. package/src/runner/changeCapture.ts +140 -5
  109. package/src/runner/runTask.ts +61 -43
  110. package/src/runner/simpleProcess.ts +223 -0
  111. package/src/runner/watch.ts +18 -2
  112. package/src/security/commandGuard.ts +46 -4
  113. package/src/security/riskEngine.ts +160 -0
  114. package/src/security/runtimeGuard.ts +41 -0
  115. package/src/smoke-test.ts +1291 -4
  116. package/src/tools/applyPatch.ts +86 -0
  117. package/src/tools/auditSession.ts +28 -0
  118. package/src/tools/auditTask.ts +100 -2
  119. package/src/tools/createDirectSession.ts +113 -0
  120. package/src/tools/createTask.ts +405 -55
  121. package/src/tools/finalizeDirectSession.ts +144 -0
  122. package/src/tools/getTaskSummary.ts +111 -1
  123. package/src/tools/healthCheck.ts +23 -6
  124. package/src/tools/readWorkspaceFile.ts +85 -2
  125. package/src/tools/registry.ts +242 -19
  126. package/src/tools/runVerification.ts +58 -0
  127. package/src/tools/savePlan.ts +57 -7
  128. package/src/tools/searchWorkspace.ts +275 -0
  129. package/src/tools/taskTemplates.ts +2 -1
  130. package/src/tools/toolCatalog.ts +35 -6
  131. package/src/tools/waitForTask.ts +3 -3
  132. package/src/version.ts +2 -2
  133. package/tsconfig.json +18 -17
  134. package/Check-PatchWarden-Health.cmd +0 -6
  135. package/Reset-PatchWarden-Tunnel-Key.cmd +0 -6
  136. package/Restart-PatchWarden.cmd +0 -19
  137. package/Start-PatchWarden-Tunnel.cmd +0 -7
  138. package/docs/release-v0.3.0.md +0 -43
  139. package/docs/release-v0.4.0.md +0 -74
@@ -1,9 +1,12 @@
1
1
  param(
2
2
  [string]$TunnelId = $env:PATCHWARDEN_TUNNEL_ID,
3
3
  [string]$Profile = "patchwarden",
4
+ [ValidateSet("chatgpt_core", "chatgpt_direct")]
5
+ [string]$ToolProfile = "chatgpt_core",
4
6
  [string]$ProxyUrl = $(if ($env:HTTPS_PROXY) { $env:HTTPS_PROXY } else { "http://127.0.0.1:7892" }),
5
7
  [string]$TunnelClientExe = $env:TUNNEL_CLIENT_EXE,
6
8
  [string]$OpencodeBin = $env:OPENCODE_BIN_DIR,
9
+ [string]$ConfigPath = $env:PATCHWARDEN_CONFIG,
7
10
  [string]$CredentialPath = $(if ($env:PATCHWARDEN_CREDENTIAL_PATH) { $env:PATCHWARDEN_CREDENTIAL_PATH } else { Join-Path $env:APPDATA "patchwarden\control-plane-api-key.dpapi" }),
8
11
  [int]$ReconnectBaseSeconds = 5,
9
12
  [int]$ReconnectMaxSeconds = 30,
@@ -12,21 +15,38 @@ param(
12
15
  [int]$WatcherMaxRestartAttempts = 5,
13
16
  [int]$WatcherHealthyResetSeconds = 60,
14
17
  [switch]$SkipWatcher,
15
- [switch]$ForgetSavedApiKey
18
+ [switch]$ForgetSavedApiKey,
19
+ [string]$HealthListenAddr = ""
16
20
  )
17
21
 
18
22
  $ErrorActionPreference = "Stop"
19
23
 
24
+ if ([string]::IsNullOrWhiteSpace($HealthListenAddr)) {
25
+ if ($Profile -eq "patchwarden-direct" -or $ToolProfile -eq "chatgpt_direct") {
26
+ $HealthListenAddr = "127.0.0.1:8081"
27
+ } else {
28
+ $HealthListenAddr = "127.0.0.1:8080"
29
+ }
30
+ }
31
+
20
32
  $ProjectRoot = Split-Path -Parent $PSScriptRoot
21
- $ConfigPath = Join-Path $ProjectRoot "patchwarden.config.json"
22
- $McpStdioLauncher = Join-Path $ProjectRoot "scripts\patchwarden-mcp-stdio.cmd"
33
+ if (-not $ConfigPath) {
34
+ $ConfigPath = Join-Path $ProjectRoot "patchwarden.config.json"
35
+ }
36
+ $McpLauncherName = if ($ToolProfile -eq "chatgpt_direct") { "patchwarden-mcp-direct.cmd" } else { "patchwarden-mcp-stdio.cmd" }
37
+ $McpStdioLauncher = Join-Path $ProjectRoot "scripts\$McpLauncherName"
23
38
  $McpStdioLauncherForTunnel = $McpStdioLauncher -replace "\\", "/"
24
39
  $OpencodeConfigHome = Join-Path $env:LOCALAPPDATA "patchwarden\opencode-config"
25
40
  $ProfilePath = Join-Path $env:APPDATA "tunnel-client\$Profile.yaml"
26
- $RuntimeDirectory = Join-Path $env:LOCALAPPDATA "patchwarden\runtime"
41
+ $RuntimeName = if ($ToolProfile -eq "chatgpt_direct") { "runtime-direct" } else { "runtime" }
42
+ $RuntimeDirectory = Join-Path $env:LOCALAPPDATA "patchwarden\$RuntimeName"
27
43
  $StatusFile = Join-Path $RuntimeDirectory "tunnel-status.json"
28
44
  $HealthUrlFile = Join-Path $RuntimeDirectory "tunnel-health-url.txt"
29
45
  $PidFile = Join-Path $RuntimeDirectory "tunnel-client.pid"
46
+ $StdoutLogFile = Join-Path $RuntimeDirectory "tunnel-client.stdout.log"
47
+ $StderrLogFile = Join-Path $RuntimeDirectory "tunnel-client.stderr.log"
48
+ $LegacyPidFile = Join-Path $env:TEMP $(if ($ToolProfile -eq "chatgpt_direct") { "patchwarden-direct.pid" } else { "patchwarden-core.pid" })
49
+ $LegacyHealthUrlFile = Join-Path $env:TEMP $(if ($ToolProfile -eq "chatgpt_direct") { "patchwarden-direct-health.url" } else { "patchwarden-core-health.url" })
30
50
  $WatcherStatusFile = Join-Path $RuntimeDirectory "watcher-status.json"
31
51
  $script:PendingCredential = $null
32
52
  $script:TunnelProcess = $null
@@ -38,6 +58,10 @@ $script:WatcherRestartAttempts = 0
38
58
  $script:WatcherHealthySince = $null
39
59
  $script:WatcherRestartExhausted = $false
40
60
 
61
+ if ($ToolProfile -eq "chatgpt_direct") {
62
+ $SkipWatcher = $true
63
+ }
64
+
41
65
  function Assert-File {
42
66
  param([string]$Path, [string]$Name)
43
67
  if (-not (Test-Path -LiteralPath $Path)) {
@@ -104,6 +128,52 @@ function Save-PendingCredential {
104
128
  Write-Host "[saved] Encrypted credential cache: $CredentialPath"
105
129
  }
106
130
 
131
+ function Protect-DiagnosticText {
132
+ param([AllowNull()][string]$Text)
133
+ if ($null -eq $Text) { return $null }
134
+ $safe = [string]$Text
135
+ if ($env:CONTROL_PLANE_API_KEY) {
136
+ $safe = $safe.Replace([string]$env:CONTROL_PLANE_API_KEY, "[REDACTED]")
137
+ }
138
+ $safe = $safe -replace '(?i)(authorization\s*:\s*bearer\s+)\S+', '$1[REDACTED]'
139
+ $safe = $safe -replace '(?i)(CONTROL_PLANE_API_KEY\s*[=:]\s*)\S+', '$1[REDACTED]'
140
+ return $safe
141
+ }
142
+
143
+ function Get-LogTail {
144
+ param([string]$Path, [int]$LineCount = 30)
145
+ if (-not (Test-Path -LiteralPath $Path)) { return @() }
146
+ try {
147
+ return @(
148
+ Get-Content -LiteralPath $Path -Tail $LineCount -Encoding UTF8 -ErrorAction Stop |
149
+ ForEach-Object { Protect-DiagnosticText -Text ([string]$_) }
150
+ )
151
+ } catch {
152
+ return @("Could not read log tail: $($_.Exception.Message)")
153
+ }
154
+ }
155
+
156
+ function Protect-LogFile {
157
+ param([string]$Path)
158
+ if (-not (Test-Path -LiteralPath $Path)) { return }
159
+ try {
160
+ $content = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 -ErrorAction Stop
161
+ $protected = Protect-DiagnosticText -Text $content
162
+ if ($protected -ne $content) {
163
+ Set-Content -LiteralPath $Path -Value $protected -Encoding UTF8 -NoNewline
164
+ }
165
+ } catch {
166
+ Write-Host "[warn] Could not sanitize diagnostic log $Path`: $($_.Exception.Message)" -ForegroundColor Yellow
167
+ }
168
+ }
169
+
170
+ function Get-LastDiagnosticLine {
171
+ param([string[]]$Lines, [string]$Fallback)
172
+ $meaningful = @($Lines | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
173
+ if ($meaningful.Count -gt 0) { return [string]$meaningful[-1] }
174
+ return $Fallback
175
+ }
176
+
107
177
  function Write-TunnelStatus {
108
178
  param(
109
179
  [string]$Status,
@@ -112,10 +182,14 @@ function Write-TunnelStatus {
112
182
  [int]$Attempt = 0,
113
183
  [Nullable[int]]$ProcessId = $null,
114
184
  [string]$LastError = $null,
115
- [string]$NextRetryAt = $null
185
+ [string]$NextRetryAt = $null,
186
+ [Nullable[int]]$ProcessExitCode = $null,
187
+ [string[]]$StdoutTail = @(),
188
+ [string[]]$StderrTail = @()
116
189
  )
117
190
  New-Item -ItemType Directory -Force -Path $RuntimeDirectory | Out-Null
118
- $safeError = if ($LastError) { ($LastError -replace '[\r\n]+', ' ').Substring(0, [Math]::Min(500, ($LastError -replace '[\r\n]+', ' ').Length)) } else { $null }
191
+ $protectedError = Protect-DiagnosticText -Text $LastError
192
+ $safeError = if ($protectedError) { ($protectedError -replace '[\r\n]+', ' ').Substring(0, [Math]::Min(500, ($protectedError -replace '[\r\n]+', ' ').Length)) } else { $null }
119
193
  $payload = [ordered]@{
120
194
  status = $Status
121
195
  reason_code = $ReasonCode
@@ -125,13 +199,19 @@ function Write-TunnelStatus {
125
199
  checked_at = (Get-Date).ToUniversalTime().ToString("o")
126
200
  next_retry_at = $NextRetryAt
127
201
  last_error = $safeError
202
+ last_exit_code = $ProcessExitCode
203
+ stdout_tail = @($StdoutTail)
204
+ stderr_tail = @($StderrTail)
205
+ stdout_log = $StdoutLogFile
206
+ stderr_log = $StderrLogFile
128
207
  server_version = if ($script:ToolManifest) { $script:ToolManifest.server_version } else { $null }
129
208
  schema_epoch = if ($script:ToolManifest) { $script:ToolManifest.schema_epoch } else { $null }
130
209
  tool_profile = if ($script:ToolManifest) { $script:ToolManifest.tool_profile } else { $null }
131
210
  tool_count = if ($script:ToolManifest) { $script:ToolManifest.tool_count } else { $null }
132
211
  tool_names = if ($script:ToolManifest) { $script:ToolManifest.tool_names } else { @() }
133
212
  tool_manifest_sha256 = if ($script:ToolManifest) { $script:ToolManifest.tool_manifest_sha256 } else { $null }
134
- core_tools_ready = [bool]($script:ToolManifest -and $script:ToolManifest.ok)
213
+ tools_ready = [bool]($script:ToolManifest -and $script:ToolManifest.ok)
214
+ core_tools_ready = [bool]($ToolProfile -eq "chatgpt_core" -and $script:ToolManifest -and $script:ToolManifest.ok)
135
215
  }
136
216
  $temporary = "$StatusFile.tmp"
137
217
  $payload | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $temporary -Encoding UTF8
@@ -215,19 +295,30 @@ function Stop-OwnedWatcherProcess {
215
295
  function Start-OwnedWatcherProcess {
216
296
  $script:WatcherInstanceId = [Guid]::NewGuid().ToString("n")
217
297
  $env:PATCHWARDEN_CONFIG = $ConfigPath
218
- $env:PATCHWARDEN_WATCHER_INSTANCE_ID = $script:WatcherInstanceId
219
- $env:PATCHWARDEN_WATCHER_LAUNCHER_PID = [string]$PID
220
- $env:XDG_CONFIG_HOME = $OpencodeConfigHome
221
- if ($OpencodeBin) { $env:PATH = "$OpencodeBin;$env:PATH" }
222
298
  $node = (Get-Command node.exe -ErrorAction Stop).Source
223
299
  $stdout = Join-Path $RuntimeDirectory "watcher-$($script:WatcherInstanceId).stdout.log"
224
300
  $stderr = Join-Path $RuntimeDirectory "watcher-$($script:WatcherInstanceId).stderr.log"
225
301
  $script:WatcherManaged = $true
226
302
  $script:WatcherHealthySince = $null
227
- $script:WatcherProcess = Start-Process -FilePath $node `
228
- -ArgumentList @((Join-Path $ProjectRoot "dist\runner\watch.js")) `
229
- -WorkingDirectory $ProjectRoot -PassThru -WindowStyle Hidden `
230
- -RedirectStandardOutput $stdout -RedirectStandardError $stderr
303
+ $previousWatcherInstanceId = $env:PATCHWARDEN_WATCHER_INSTANCE_ID
304
+ $previousWatcherLauncherPid = $env:PATCHWARDEN_WATCHER_LAUNCHER_PID
305
+ $previousXdgConfigHome = $env:XDG_CONFIG_HOME
306
+ $previousPath = $env:PATH
307
+ try {
308
+ $env:PATCHWARDEN_WATCHER_INSTANCE_ID = $script:WatcherInstanceId
309
+ $env:PATCHWARDEN_WATCHER_LAUNCHER_PID = [string]$PID
310
+ $env:XDG_CONFIG_HOME = $OpencodeConfigHome
311
+ if ($OpencodeBin) { $env:PATH = "$OpencodeBin;$env:PATH" }
312
+ $script:WatcherProcess = Start-Process -FilePath $node `
313
+ -ArgumentList @((Join-Path $ProjectRoot "dist\runner\watch.js")) `
314
+ -WorkingDirectory $ProjectRoot -PassThru -WindowStyle Hidden `
315
+ -RedirectStandardOutput $stdout -RedirectStandardError $stderr
316
+ } finally {
317
+ if ($null -eq $previousWatcherInstanceId) { Remove-Item Env:PATCHWARDEN_WATCHER_INSTANCE_ID -ErrorAction SilentlyContinue } else { $env:PATCHWARDEN_WATCHER_INSTANCE_ID = $previousWatcherInstanceId }
318
+ if ($null -eq $previousWatcherLauncherPid) { Remove-Item Env:PATCHWARDEN_WATCHER_LAUNCHER_PID -ErrorAction SilentlyContinue } else { $env:PATCHWARDEN_WATCHER_LAUNCHER_PID = $previousWatcherLauncherPid }
319
+ if ($null -eq $previousXdgConfigHome) { Remove-Item Env:XDG_CONFIG_HOME -ErrorAction SilentlyContinue } else { $env:XDG_CONFIG_HOME = $previousXdgConfigHome }
320
+ $env:PATH = $previousPath
321
+ }
231
322
  Write-WatcherStatus -Status "starting"
232
323
  Write-Host "[watch] Started owned watcher PID $($script:WatcherProcess.Id), instance $($script:WatcherInstanceId)."
233
324
  }
@@ -280,17 +371,119 @@ function Get-TunnelHealth {
280
371
  }
281
372
 
282
373
  function Stop-OwnedTunnelProcess {
374
+ if (Test-Path -LiteralPath $PidFile) {
375
+ $rawPid = $null
376
+ try { $rawPid = (Get-Content -LiteralPath $PidFile -Raw -ErrorAction Stop).Trim() } catch {}
377
+ $childPid = 0
378
+ if ($rawPid -and [int]::TryParse($rawPid, [ref]$childPid) -and $childPid -gt 0) {
379
+ $childProcess = Get-ProcessByIdSafe -ProcessId $childPid
380
+ if ($childProcess -and (Test-TunnelProcessForProfile -Process $childProcess)) {
381
+ Stop-Process -Id $childPid -ErrorAction SilentlyContinue
382
+ }
383
+ }
384
+ }
283
385
  if ($script:TunnelProcess -and -not $script:TunnelProcess.HasExited) {
284
386
  Stop-Process -Id $script:TunnelProcess.Id -ErrorAction SilentlyContinue
285
387
  try { $script:TunnelProcess.WaitForExit(5000) | Out-Null } catch {}
286
388
  }
287
389
  }
288
390
 
391
+ function Get-ProcessByIdSafe {
392
+ param([Nullable[int]]$ProcessId)
393
+ if (-not $ProcessId -or $ProcessId -le 0) { return $null }
394
+ try {
395
+ return Get-CimInstance Win32_Process -Filter "ProcessId = $ProcessId" -ErrorAction Stop
396
+ } catch {
397
+ return $null
398
+ }
399
+ }
400
+
401
+ function Test-TunnelProcessForProfile {
402
+ param($Process)
403
+ if (-not $Process -or [string]$Process.Name -ine "tunnel-client.exe") { return $false }
404
+ $commandLine = [string]$Process.CommandLine
405
+ if (-not $commandLine) { return $false }
406
+ $profilePattern = '(?i)"?--profile"?(?:=|\s+)"?' + [Regex]::Escape($Profile) + '(?:"|\s|$)'
407
+ $normalizedCommand = $commandLine -replace '\\', '/'
408
+ $normalizedProfilePath = $ProfilePath -replace '\\', '/'
409
+ return $commandLine -match $profilePattern -or $normalizedCommand.IndexOf($normalizedProfilePath, [StringComparison]::OrdinalIgnoreCase) -ge 0
410
+ }
411
+
412
+ function Clear-StaleRunFiles {
413
+ foreach ($candidatePidFile in @($PidFile, $LegacyPidFile)) {
414
+ if (-not (Test-Path -LiteralPath $candidatePidFile)) { continue }
415
+ $rawPid = $null
416
+ try { $rawPid = (Get-Content -LiteralPath $candidatePidFile -Raw -ErrorAction Stop).Trim() } catch {}
417
+ $parsedPid = 0
418
+ if ($rawPid -and [int]::TryParse($rawPid, [ref]$parsedPid) -and $parsedPid -gt 0) {
419
+ $existingProcess = Get-ProcessByIdSafe -ProcessId $parsedPid
420
+ if ($existingProcess) {
421
+ if (Test-TunnelProcessForProfile -Process $existingProcess) {
422
+ $modeName = if ($ToolProfile -eq "chatgpt_direct") { "direct" } else { "core" }
423
+ throw "[conflict] Existing tunnel-client PID $parsedPid still uses profile $Profile. Run PatchWarden.cmd restart $modeName or kill it explicitly."
424
+ }
425
+ Write-Host "[cleanup] Stale PID file $candidatePidFile references unrelated PID $parsedPid ($($existingProcess.Name)); the process will not be stopped." -ForegroundColor Yellow
426
+ }
427
+ }
428
+ }
429
+
430
+ foreach ($path in @($PidFile, $HealthUrlFile, $LegacyPidFile, $LegacyHealthUrlFile)) {
431
+ if (Test-Path -LiteralPath $path) {
432
+ Remove-Item -LiteralPath $path -Force -ErrorAction Stop
433
+ Write-Host "[cleanup] Removed stale runtime file: $path"
434
+ }
435
+ }
436
+ }
437
+
438
+ function Get-TrackedTunnelProcessId {
439
+ if (Test-Path -LiteralPath $PidFile) {
440
+ $rawPid = $null
441
+ try { $rawPid = (Get-Content -LiteralPath $PidFile -Raw -ErrorAction Stop).Trim() } catch {}
442
+ $trackedPid = 0
443
+ if ($rawPid -and [int]::TryParse($rawPid, [ref]$trackedPid) -and $trackedPid -gt 0) {
444
+ $trackedProcess = Get-ProcessByIdSafe -ProcessId $trackedPid
445
+ if ($trackedProcess -and (Test-TunnelProcessForProfile -Process $trackedProcess)) {
446
+ return $trackedPid
447
+ }
448
+ }
449
+ }
450
+ if ($script:TunnelProcess -and -not $script:TunnelProcess.HasExited) { return $script:TunnelProcess.Id }
451
+ return $null
452
+ }
453
+
289
454
  function Quote-ProcessArgument {
290
455
  param([string]$Value)
291
456
  return '"' + ($Value -replace '"', '\"') + '"'
292
457
  }
293
458
 
459
+ function Set-ProfileHealthListenAddr {
460
+ param([string]$ProfilePath, [string]$HealthListenAddr)
461
+ if (-not (Test-Path -LiteralPath $ProfilePath)) { return }
462
+ $content = Get-Content -LiteralPath $ProfilePath -Raw
463
+ $target = [Regex]::Escape($HealthListenAddr)
464
+ # Already correct; nothing to do (idempotent)
465
+ if ($content -match "listen_addr:\s*`"$target`"") { return }
466
+ # Has a health block with a different listen_addr; replace the value
467
+ if ($content -match 'listen_addr:\s*"[^"]*"') {
468
+ $updated = $content -replace '(listen_addr:\s*)"[^"]*"', "`$1`"$HealthListenAddr`""
469
+ Set-Content -LiteralPath $ProfilePath -Value $updated -Encoding UTF8 -NoNewline
470
+ Write-Host "[config] Updated health.listen_addr to $HealthListenAddr in $Profile"
471
+ return
472
+ }
473
+ # Has a health: section but no listen_addr; insert after health:
474
+ if ($content -match '(?m)^health:') {
475
+ $updated = $content -replace '(?m)^(health:.*)$', "`$1`n listen_addr: `"$HealthListenAddr`""
476
+ Set-Content -LiteralPath $ProfilePath -Value $updated -Encoding UTF8 -NoNewline
477
+ Write-Host "[config] Added health.listen_addr: $HealthListenAddr to existing health block in $Profile"
478
+ return
479
+ }
480
+ # No health block at all; append one
481
+ $trimmed = $content.TrimEnd()
482
+ $updated = "$trimmed`n`nhealth:`n listen_addr: `"$HealthListenAddr`"`n"
483
+ Set-Content -LiteralPath $ProfilePath -Value $updated -Encoding UTF8 -NoNewline
484
+ Write-Host "[config] Added health block with listen_addr: $HealthListenAddr to $Profile"
485
+ }
486
+
294
487
  if ($ForgetSavedApiKey) {
295
488
  if (Test-Path -LiteralPath $CredentialPath) {
296
489
  Remove-Item -LiteralPath $CredentialPath -Force
@@ -312,21 +505,21 @@ if (-not $TunnelClientExe) {
312
505
  $TunnelClientExe = Read-Host "Path to tunnel-client.exe"
313
506
  }
314
507
 
315
- if (-not $OpencodeBin) {
508
+ if (-not $SkipWatcher -and -not $OpencodeBin) {
316
509
  $candidateOpencodeBin = Join-Path $env:APPDATA "npm\node_modules\opencode-ai\bin"
317
510
  if (Test-Path -LiteralPath $candidateOpencodeBin) {
318
511
  $OpencodeBin = $candidateOpencodeBin
319
512
  }
320
513
  }
321
514
 
322
- if (-not $OpencodeBin) {
515
+ if (-not $SkipWatcher -and -not $OpencodeBin) {
323
516
  Write-Host "[warn] OPENCODE_BIN_DIR is not set and opencode-ai bin was not found under APPDATA."
324
517
  Write-Host " Watcher will still start, but opencode tasks may fail unless opencode is on PATH."
325
518
  }
326
519
 
327
520
  Assert-File -Path $TunnelClientExe -Name "tunnel-client.exe"
328
521
  Assert-File -Path $ConfigPath -Name "patchwarden.config.json"
329
- Assert-File -Path $McpStdioLauncher -Name "patchwarden-mcp-stdio.cmd"
522
+ Assert-File -Path $McpStdioLauncher -Name $McpLauncherName
330
523
 
331
524
  if (-not $TunnelId) {
332
525
  $TunnelId = Read-Host "Tunnel ID"
@@ -344,8 +537,8 @@ if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot "dist\index.js"))) {
344
537
 
345
538
  New-Item -ItemType Directory -Force -Path $RuntimeDirectory | Out-Null
346
539
  $env:PATCHWARDEN_CONFIG = $ConfigPath
347
- Write-Host "[manifest] Verifying the exact tunnel stdio MCP tool catalog..."
348
- $manifestOutput = (& node (Join-Path $ProjectRoot "scripts\mcp-manifest-check.js") --json 2>&1 | Out-String).Trim()
540
+ Write-Host "[manifest] Verifying the exact $ToolProfile stdio MCP tool catalog..."
541
+ $manifestOutput = (& node (Join-Path $ProjectRoot "scripts\mcp-manifest-check.js") --profile $ToolProfile --json 2>&1 | Out-String).Trim()
349
542
  if ($LASTEXITCODE -ne 0) {
350
543
  Write-TunnelStatus -Status "stopped" -ReasonCode "tool_manifest_check_failed" -LastError "The tunnel MCP tool manifest preflight failed."
351
544
  throw "Tool manifest preflight failed: $manifestOutput"
@@ -359,6 +552,9 @@ try {
359
552
  $manifestFile = Join-Path $RuntimeDirectory "tool-manifest.json"
360
553
  $manifestOutput | Set-Content -LiteralPath $manifestFile -Encoding UTF8
361
554
  Write-Host "[manifest] $($script:ToolManifest.server_version) profile=$($script:ToolManifest.tool_profile) tools=$($script:ToolManifest.tool_count) hash=$($script:ToolManifest.tool_manifest_sha256)"
555
+ Write-Host "[config] tunnel profile: $Profile"
556
+ Write-Host "[config] tool profile: $ToolProfile"
557
+ Write-Host "[config] health listen addr: $HealthListenAddr"
362
558
 
363
559
  Set-SecretEnvIfMissing
364
560
 
@@ -382,6 +578,9 @@ if ($profileNeedsInit) {
382
578
  --tunnel-id $TunnelId `
383
579
  --mcp-command $McpStdioLauncherForTunnel `
384
580
  --force
581
+ Set-ProfileHealthListenAddr -ProfilePath $ProfilePath -HealthListenAddr $HealthListenAddr
582
+ } else {
583
+ Set-ProfileHealthListenAddr -ProfilePath $ProfilePath -HealthListenAddr $HealthListenAddr
385
584
  }
386
585
 
387
586
  $env:PATCHWARDEN_TUNNEL_STATUS_FILE = $StatusFile
@@ -400,7 +599,7 @@ while ($doctor.ExitCode -ne 0 -and $doctor.ReasonCode -eq "transient_connection_
400
599
  }
401
600
  if ($doctor.ExitCode -ne 0) {
402
601
  if ($doctor.ReasonCode -eq "transient_connection_failure") {
403
- Write-Host "[doctor] Transient connection failure persists after $preflightAttempt retries; starting tunnel anyway (supervisor will retry during runtime)." -ForegroundColor Yellow
602
+ Write-Host "[doctor] Preflight transient; continuing to supervised run after $preflightAttempt retries." -ForegroundColor Yellow
404
603
  Write-TunnelStatus -Status "degraded" -ReasonCode $doctor.ReasonCode -Ready $false -LastError "Preflight doctor reported transient connection failure; proceeding with supervisor-managed recovery."
405
604
  } else {
406
605
  Write-TunnelStatus -Status "stopped" -ReasonCode $doctor.ReasonCode -LastError "tunnel-client doctor failed; review the launcher output."
@@ -416,7 +615,18 @@ Save-PendingCredential
416
615
 
417
616
  Write-Host ""
418
617
  Write-Host "[run] Starting supervised tunnel-client. Keep this window open."
419
- Write-Host "[health] Run Check-PatchWarden-Health.cmd if ChatGPT cannot reach the MCP."
618
+ Write-Host "[health] Run PatchWarden.cmd status all if ChatGPT cannot reach the MCP."
619
+ Write-Host "[run-config] tunnel-client exe: $TunnelClientExe"
620
+ Write-Host "[run-config] profile: $Profile"
621
+ Write-Host "[run-config] health listen addr: $HealthListenAddr"
622
+ Write-Host "[run-config] health url file: $HealthUrlFile"
623
+ Write-Host "[run-config] pid file: $PidFile"
624
+ Write-Host "[run-config] stdout log: $StdoutLogFile"
625
+ Write-Host "[run-config] stderr log: $StderrLogFile"
626
+ Write-Host "[run-config] HTTPS_PROXY: $env:HTTPS_PROXY"
627
+ Write-Host "[run-config] HTTP_PROXY: $env:HTTP_PROXY"
628
+ Write-Host "[run-config] CONTROL_PLANE_API_KEY: $(if ($env:CONTROL_PLANE_API_KEY) { 'set' } else { 'missing' })"
629
+ Write-Host "[run-config] cwd: $ProjectRoot"
420
630
  Write-Host ""
421
631
 
422
632
  $attempt = 0
@@ -424,7 +634,8 @@ $openUi = $true
424
634
  try {
425
635
  while ($true) {
426
636
  $attempt++
427
- Remove-Item -LiteralPath $HealthUrlFile, $PidFile -Force -ErrorAction SilentlyContinue
637
+ Clear-StaleRunFiles
638
+ Remove-Item -LiteralPath $StdoutLogFile, $StderrLogFile -Force -ErrorAction SilentlyContinue
428
639
  Write-TunnelStatus -Status "starting" -ReasonCode $null -Ready $false -Attempt $attempt
429
640
  $runArguments = @(
430
641
  "run", "--profile", $Profile,
@@ -435,9 +646,13 @@ try {
435
646
  )
436
647
  if ($openUi) { $runArguments += "--open-web-ui"; $openUi = $false }
437
648
  $argumentLine = ($runArguments | ForEach-Object { Quote-ProcessArgument $_ }) -join " "
438
- $script:TunnelProcess = Start-Process -FilePath $TunnelClientExe -ArgumentList $argumentLine -PassThru -WindowStyle Hidden
649
+ Write-Host "[run-config] command: tunnel-client $($runArguments -join ' ')"
650
+ $redirectedCommand = '""' + $TunnelClientExe + '" ' + $argumentLine + ' 1>"' + $StdoutLogFile + '" 2>"' + $StderrLogFile + '""'
651
+ $processArgumentLine = '/d /s /c ' + $redirectedCommand
652
+ $script:TunnelProcess = Start-Process -FilePath $env:ComSpec -ArgumentList $processArgumentLine `
653
+ -WorkingDirectory $ProjectRoot -PassThru -WindowStyle Hidden
439
654
  Write-TunnelStatus -Status "connecting" -ReasonCode $null -Ready $false -Attempt $attempt -ProcessId $script:TunnelProcess.Id
440
- Write-Host "[run] tunnel-client PID $($script:TunnelProcess.Id), attempt $attempt"
655
+ Write-Host "[run] supervised command PID $($script:TunnelProcess.Id), attempt $attempt"
441
656
 
442
657
  $unreadySince = $null
443
658
  $restartForHealth = $false
@@ -447,11 +662,11 @@ try {
447
662
  $health = Get-TunnelHealth
448
663
  if ($health -and $health.healthz.ok -and $health.readyz.ok) {
449
664
  $unreadySince = $null
450
- Write-TunnelStatus -Status "ready" -ReasonCode $null -Ready $true -Attempt $attempt -ProcessId $script:TunnelProcess.Id
665
+ Write-TunnelStatus -Status "ready" -ReasonCode $null -Ready $true -Attempt $attempt -ProcessId (Get-TrackedTunnelProcessId)
451
666
  continue
452
667
  }
453
668
  if (-not $unreadySince) { $unreadySince = Get-Date }
454
- Write-TunnelStatus -Status "degraded" -ReasonCode "tunnel_not_ready" -Ready $false -Attempt $attempt -ProcessId $script:TunnelProcess.Id -LastError "Tunnel process is running but /readyz is not ready."
669
+ Write-TunnelStatus -Status "degraded" -ReasonCode "tunnel_not_ready" -Ready $false -Attempt $attempt -ProcessId (Get-TrackedTunnelProcessId) -LastError "Tunnel process is running but /readyz is not ready."
455
670
  if (((Get-Date) - $unreadySince).TotalSeconds -ge $UnreadyRestartSeconds) {
456
671
  $doctor = Invoke-TunnelDoctor
457
672
  if ($doctor.ExitCode -ne 0 -and $doctor.ReasonCode -ne "transient_connection_failure") {
@@ -466,21 +681,37 @@ try {
466
681
  }
467
682
  }
468
683
 
684
+ try { [void]$script:TunnelProcess.WaitForExit(5000) } catch {}
685
+ try { $script:TunnelProcess.Refresh() } catch {}
469
686
  $exitCode = $script:TunnelProcess.ExitCode
687
+ Protect-LogFile -Path $StdoutLogFile
688
+ Protect-LogFile -Path $StderrLogFile
689
+ $stdoutTail = @(Get-LogTail -Path $StdoutLogFile -LineCount 30)
690
+ $stderrTail = @(Get-LogTail -Path $StderrLogFile -LineCount 30)
691
+ $exitError = Get-LastDiagnosticLine -Lines $stderrTail -Fallback "Tunnel process exited with code $exitCode."
692
+ if ($exitCode -ne 0) {
693
+ Write-Host "[error] tunnel-client exited with code $exitCode." -ForegroundColor Red
694
+ Write-Host "[error] stderr tail:" -ForegroundColor Red
695
+ if ($stderrTail.Count -gt 0) {
696
+ $stderrTail | ForEach-Object { Write-Host $_ -ForegroundColor DarkRed }
697
+ } else {
698
+ Write-Host "(stderr log was empty)" -ForegroundColor DarkRed
699
+ }
700
+ }
470
701
  $doctor = Invoke-TunnelDoctor
471
702
  if ($doctor.ExitCode -ne 0 -and $doctor.ReasonCode -ne "transient_connection_failure") {
472
- Write-TunnelStatus -Status "stopped" -ReasonCode $doctor.ReasonCode -Ready $false -Attempt $attempt -LastError "Tunnel exited and doctor reported a non-retryable error."
703
+ Write-TunnelStatus -Status "stopped" -ReasonCode $doctor.ReasonCode -Ready $false -Attempt $attempt -LastError $exitError -ProcessExitCode $exitCode -StdoutTail $stdoutTail -StderrTail $stderrTail
473
704
  Write-Host "[doctor] Non-retryable diagnostic: $($doctor.ReasonCode)"
474
705
  throw "Tunnel stopped after non-retryable error: $($doctor.ReasonCode)"
475
706
  }
476
707
  if ($MaxReconnectAttempts -gt 0 -and $attempt -ge $MaxReconnectAttempts) {
477
- Write-TunnelStatus -Status "stopped" -ReasonCode "retry_limit_reached" -Ready $false -Attempt $attempt -LastError "Reconnect attempt limit reached."
708
+ Write-TunnelStatus -Status "stopped" -ReasonCode "retry_limit_reached" -Ready $false -Attempt $attempt -LastError $exitError -ProcessExitCode $exitCode -StdoutTail $stdoutTail -StderrTail $stderrTail
478
709
  throw "Tunnel reconnect attempt limit reached."
479
710
  }
480
711
  $delay = [Math]::Min($ReconnectMaxSeconds, $ReconnectBaseSeconds * [Math]::Pow(2, [Math]::Min(3, $attempt - 1)))
481
712
  $nextRetry = (Get-Date).AddSeconds($delay).ToUniversalTime().ToString("o")
482
713
  $reason = if ($restartForHealth) { "tunnel_not_ready" } else { "tunnel_process_exited" }
483
- Write-TunnelStatus -Status "reconnecting" -ReasonCode $reason -Ready $false -Attempt $attempt -LastError "Tunnel process exited with code $exitCode; retrying." -NextRetryAt $nextRetry
714
+ Write-TunnelStatus -Status "reconnecting" -ReasonCode $reason -Ready $false -Attempt $attempt -LastError $exitError -NextRetryAt $nextRetry -ProcessExitCode $exitCode -StdoutTail $stdoutTail -StderrTail $stderrTail
484
715
  Write-Host "[retry] tunnel-client exited with code $exitCode; retrying in $delay seconds."
485
716
  Start-Sleep -Seconds $delay
486
717
  }
@@ -14,10 +14,19 @@ const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
14
14
  const temp = mkdtempSync(join(tmpdir(), "patchwarden-tunnel-smoke-"));
15
15
  const mockJs = join(temp, "mock-tunnel-client.js");
16
16
  const mockCmd = join(temp, "mock-tunnel-client.cmd");
17
+ const mockConfig = join(temp, "patchwarden.config.json");
17
18
  const stateFile = join(temp, "attempt.txt");
18
19
  const secretMarker = "smoke-secret-must-not-appear";
19
20
 
20
21
  try {
22
+ writeFileSync(mockConfig, JSON.stringify({
23
+ workspaceRoot: temp,
24
+ plansDir: ".patchwarden/plans",
25
+ tasksDir: ".patchwarden/tasks",
26
+ toolProfile: "chatgpt_core",
27
+ agents: { smoke: { command: process.execPath, args: [] } },
28
+ allowedTestCommands: ["npm test"],
29
+ }, null, 2), "utf-8");
21
30
  writeFileSync(mockCmd, `@echo off\r\nnode "%MOCK_TUNNEL_JS%" %*\r\n`, "utf-8");
22
31
  writeFileSync(mockJs, `
23
32
  const fs=require('fs');
@@ -30,11 +39,12 @@ if(command==='health'){console.log(JSON.stringify({healthz:{ok:true},readyz:{ok:
30
39
  if(command==='run'){
31
40
  let attempt=0;try{attempt=Number(fs.readFileSync(process.env.MOCK_TUNNEL_STATE,'utf8'))||0}catch{}
32
41
  attempt++;fs.writeFileSync(process.env.MOCK_TUNNEL_STATE,String(attempt));
33
- if(attempt===1)process.exit(7);
42
+ console.log('fixture cwd='+process.cwd());
43
+ if(attempt===1){console.error('fixture first attempt failed');process.exit(7)}
34
44
  const urlFile=flag('--health.url-file');const pidFile=flag('--pid.file');
35
45
  const server=http.createServer((req,res)=>{res.writeHead(200,{'content-type':'application/json'});res.end(JSON.stringify({ok:true}))});
36
46
  server.listen(18888,'127.0.0.1',()=>{fs.writeFileSync(urlFile,'http://127.0.0.1:18888');fs.writeFileSync(pidFile,String(process.pid))});
37
- setTimeout(()=>server.close(()=>process.exit(9)),6500);
47
+ setTimeout(()=>server.close(()=>{for(let i=1;i<=35;i++)console.error('fixture stderr line '+i);console.error('CONTROL_PLANE_API_KEY='+process.env.CONTROL_PLANE_API_KEY);process.exit(9)}),6500);
38
48
  }
39
49
  `, "utf-8");
40
50
 
@@ -50,6 +60,7 @@ if(command==='run'){
50
60
  "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", join(root, "scripts", "start-patchwarden-tunnel.ps1"),
51
61
  "-TunnelId", "tunnel_smoke_fixture",
52
62
  "-TunnelClientExe", mockCmd,
63
+ "-ConfigPath", mockConfig,
53
64
  "-ProxyUrl", "http://127.0.0.1:1",
54
65
  "-ReconnectBaseSeconds", "1",
55
66
  "-ReconnectMaxSeconds", "1",
@@ -59,6 +70,7 @@ if(command==='run'){
59
70
  ], { cwd: root, env, encoding: "utf-8", timeout: 30_000 });
60
71
 
61
72
  if (result.error) throw result.error;
73
+ const launcherOutput = `${result.stdout}\n${result.stderr}`;
62
74
  if (!existsSync(stateFile) || readFileSync(stateFile, "utf-8").trim() !== "2") {
63
75
  throw new Error(`Expected two supervised attempts. stdout=${result.stdout} stderr=${result.stderr}`);
64
76
  }
@@ -67,11 +79,31 @@ if(command==='run'){
67
79
  if (status.reason_code !== "retry_limit_reached" || status.attempt !== 2) {
68
80
  throw new Error(`Unexpected final supervisor status: ${JSON.stringify(status)}`);
69
81
  }
82
+ if (status.last_exit_code !== 9 || !Array.isArray(status.stderr_tail) || status.stderr_tail.length !== 30) {
83
+ throw new Error(`Supervisor did not preserve the latest exit diagnostics: ${JSON.stringify(status)}\n${launcherOutput}`);
84
+ }
85
+ if (!status.stdout_log?.endsWith("tunnel-client.stdout.log") || !status.stderr_log?.endsWith("tunnel-client.stderr.log")) {
86
+ throw new Error(`Supervisor log paths are missing: ${JSON.stringify(status)}`);
87
+ }
88
+ if (!existsSync(status.stdout_log) || !existsSync(status.stderr_log)) {
89
+ throw new Error(`Supervisor log files were not created: ${JSON.stringify(status)}`);
90
+ }
91
+ const stdoutLog = readFileSync(status.stdout_log, "utf-8").replace(/^\uFEFF/, "");
92
+ const stderrLog = readFileSync(status.stderr_log, "utf-8").replace(/^\uFEFF/, "");
93
+ if (!stdoutLog.includes(`fixture cwd=${root}`)) {
94
+ throw new Error(`Tunnel child did not inherit the project working directory: ${stdoutLog}`);
95
+ }
96
+ if (!stderrLog.includes("fixture stderr line 35") || !stderrLog.includes("[REDACTED]")) {
97
+ throw new Error(`Supervisor stderr log was not captured and sanitized: ${stderrLog}`);
98
+ }
70
99
  const serialized = JSON.stringify(status);
71
- if (serialized.includes(secretMarker) || serialized.includes("tunnel_smoke_fixture")) {
100
+ if (serialized.includes(secretMarker) || launcherOutput.includes(secretMarker) || stdoutLog.includes(secretMarker) || stderrLog.includes(secretMarker) || serialized.includes("tunnel_smoke_fixture")) {
72
101
  throw new Error("Supervisor status leaked credential or tunnel identifier material");
73
102
  }
74
- console.log("ok - tunnel supervisor retries, probes readiness, and writes redacted status");
103
+ if (!launcherOutput.includes("[error] stderr tail:") || !launcherOutput.includes("fixture stderr line 35")) {
104
+ throw new Error(`Supervisor did not print the stderr tail: ${launcherOutput}`);
105
+ }
106
+ console.log("ok - tunnel supervisor captures logs, redacts tails, fixes cwd, retries, and writes structured status");
75
107
  } finally {
76
108
  rmSync(temp, { recursive: true, force: true });
77
109
  }
@@ -37,7 +37,7 @@ try {
37
37
  });
38
38
 
39
39
  await runExternalScenario();
40
- console.log("ok - watcher supervisor handles exit, stale heartbeat, retry limit, and external ownership");
40
+ console.log("ok - watcher supervisor isolates environment and handles exit, stale heartbeat, retry limit, and external ownership");
41
41
  } finally {
42
42
  for (const child of children) {
43
43
  if (child.exitCode === null) child.kill("SIGKILL");
@@ -67,6 +67,7 @@ async function runExternalScenario() {
67
67
  PATCHWARDEN_CONFIG: fixture.configPath,
68
68
  PATCHWARDEN_WATCHER_INSTANCE_ID: "external-fixture",
69
69
  PATCHWARDEN_WATCHER_LAUNCHER_PID: "999999",
70
+ XDG_CONFIG_HOME: join(fixture.localAppData, "patchwarden", "opencode-config"),
70
71
  },
71
72
  stdio: "ignore",
72
73
  });
@@ -95,8 +96,8 @@ function createFixture(name) {
95
96
  writeFileSync(join(scripts, "patchwarden-mcp-stdio.cmd"), "@echo off\r\nexit /b 0\r\n", "utf-8");
96
97
  const manifestFixture = JSON.stringify({
97
98
  ok: true,
98
- server_version: "0.4.0",
99
- schema_epoch: "2026-06-20-v2",
99
+ server_version: "0.6.0",
100
+ schema_epoch: "2026-06-22-v6",
100
101
  tool_profile: "chatgpt_core",
101
102
  tool_count: 16,
102
103
  tool_names: [],
@@ -144,6 +145,7 @@ function runLauncher(fixture, mode, maxRestarts, lifetimeMs) {
144
145
  "-TunnelClientExe", fixture.mockCmd,
145
146
  "-ProxyUrl", "http://127.0.0.1:1",
146
147
  "-MaxReconnectAttempts", "1",
148
+ "-HealthListenAddr", "127.0.0.1:8080",
147
149
  "-WatcherMaxRestartAttempts", String(maxRestarts),
148
150
  "-WatcherHealthyResetSeconds", "60",
149
151
  ], {
@@ -168,6 +170,7 @@ function fixtureEnv(fixture, mode, lifetimeMs) {
168
170
  function watcherFixtureSource(attemptPath) {
169
171
  return `
170
172
  const fs=require('fs');const path=require('path');
173
+ if(!process.env.XDG_CONFIG_HOME){console.error('watcher did not receive XDG_CONFIG_HOME');process.exit(12)}
171
174
  let attempt=0;try{attempt=Number(fs.readFileSync(${JSON.stringify(attemptPath)},'utf8'))||0}catch{}
172
175
  attempt++;fs.writeFileSync(${JSON.stringify(attemptPath)},String(attempt));
173
176
  const cfg=JSON.parse(fs.readFileSync(process.env.PATCHWARDEN_CONFIG,'utf8'));
@@ -182,11 +185,12 @@ else setInterval(write,250);
182
185
 
183
186
  function tunnelFixtureSource() {
184
187
  return `
185
- const fs=require('fs');const args=process.argv.slice(2);const command=args[0]||'';
188
+ const fs=require('fs');const path=require('path');const args=process.argv.slice(2);const command=args[0]||'';
186
189
  const flag=(name)=>{const i=args.indexOf(name);return i>=0?args[i+1]:''};
187
- if(command==='init'||command==='doctor'){console.log('{}');process.exit(0)}
190
+ if(command==='init'){const profile=flag('--profile')||'patchwarden';const mcpCommand=flag('--mcp-command')||'';const yamlDir=path.join(process.env.APPDATA,'tunnel-client');const yamlPath=path.join(yamlDir,profile+'.yaml');fs.mkdirSync(yamlDir,{recursive:true});fs.writeFileSync(yamlPath,'mcp:\\n commands:\\n - channel: main\\n command: \"'+mcpCommand.replace(/\\\\/g,'/')+'\"\\n\\nhealth:\\n listen_addr: \"127.0.0.1:8080\"\\n');console.log('{}');process.exit(0)}
191
+ if(command==='doctor'){console.log('{}');process.exit(0)}
188
192
  if(command==='health'){console.log(JSON.stringify({healthz:{ok:true},readyz:{ok:true}}));process.exit(0)}
189
- if(command==='run'){const url=flag('--health.url-file'),pid=flag('--pid.file');fs.mkdirSync(require('path').dirname(url),{recursive:true});fs.writeFileSync(url,'http://127.0.0.1:18889');fs.writeFileSync(pid,String(process.pid));setTimeout(()=>process.exit(9),Number(process.env.TUNNEL_FIXTURE_LIFETIME_MS)||9000)}
193
+ if(command==='run'){if(process.env.XDG_CONFIG_HOME){console.error('watcher XDG_CONFIG_HOME leaked into tunnel-client');process.exit(21)}const url=flag('--health.url-file'),pid=flag('--pid.file');fs.mkdirSync(path.dirname(url),{recursive:true});fs.writeFileSync(url,'http://127.0.0.1:18889');fs.writeFileSync(pid,String(process.pid));setTimeout(()=>process.exit(9),Number(process.env.TUNNEL_FIXTURE_LIFETIME_MS)||9000)}
190
194
  `;
191
195
  }
192
196