patchwarden 0.4.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 (153) hide show
  1. package/Check-PatchWarden-Health.cmd +6 -0
  2. package/LICENSE +21 -0
  3. package/README.md +526 -0
  4. package/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
  5. package/Restart-PatchWarden.cmd +19 -0
  6. package/Start-PatchWarden-Tunnel.cmd +7 -0
  7. package/dist/config.d.ts +24 -0
  8. package/dist/config.js +131 -0
  9. package/dist/doctor.d.ts +10 -0
  10. package/dist/doctor.js +388 -0
  11. package/dist/errors.d.ts +16 -0
  12. package/dist/errors.js +27 -0
  13. package/dist/httpServer.d.ts +17 -0
  14. package/dist/httpServer.js +255 -0
  15. package/dist/index.d.ts +9 -0
  16. package/dist/index.js +24 -0
  17. package/dist/runner/changeCapture.d.ts +43 -0
  18. package/dist/runner/changeCapture.js +245 -0
  19. package/dist/runner/cli.d.ts +9 -0
  20. package/dist/runner/cli.js +26 -0
  21. package/dist/runner/runTask.d.ts +8 -0
  22. package/dist/runner/runTask.js +821 -0
  23. package/dist/runner/watch.d.ts +19 -0
  24. package/dist/runner/watch.js +136 -0
  25. package/dist/security/commandGuard.d.ts +22 -0
  26. package/dist/security/commandGuard.js +56 -0
  27. package/dist/security/contentRedaction.d.ts +12 -0
  28. package/dist/security/contentRedaction.js +65 -0
  29. package/dist/security/pathGuard.d.ts +3 -0
  30. package/dist/security/pathGuard.js +98 -0
  31. package/dist/security/planGuard.d.ts +1 -0
  32. package/dist/security/planGuard.js +65 -0
  33. package/dist/security/sensitiveGuard.d.ts +2 -0
  34. package/dist/security/sensitiveGuard.js +60 -0
  35. package/dist/smoke-test.d.ts +15 -0
  36. package/dist/smoke-test.js +989 -0
  37. package/dist/taskProgress.d.ts +6 -0
  38. package/dist/taskProgress.js +36 -0
  39. package/dist/taskRuntime.d.ts +10 -0
  40. package/dist/taskRuntime.js +25 -0
  41. package/dist/tools/auditTask.d.ts +19 -0
  42. package/dist/tools/auditTask.js +307 -0
  43. package/dist/tools/cancelTask.d.ts +30 -0
  44. package/dist/tools/cancelTask.js +64 -0
  45. package/dist/tools/createTask.d.ts +42 -0
  46. package/dist/tools/createTask.js +188 -0
  47. package/dist/tools/getPlan.d.ts +12 -0
  48. package/dist/tools/getPlan.js +29 -0
  49. package/dist/tools/getTaskFile.d.ts +24 -0
  50. package/dist/tools/getTaskFile.js +84 -0
  51. package/dist/tools/getTaskProgress.d.ts +1 -0
  52. package/dist/tools/getTaskProgress.js +4 -0
  53. package/dist/tools/getTaskStatus.d.ts +46 -0
  54. package/dist/tools/getTaskStatus.js +36 -0
  55. package/dist/tools/getTaskStdoutTail.d.ts +9 -0
  56. package/dist/tools/getTaskStdoutTail.js +56 -0
  57. package/dist/tools/getTaskSummary.d.ts +66 -0
  58. package/dist/tools/getTaskSummary.js +237 -0
  59. package/dist/tools/healthCheck.d.ts +79 -0
  60. package/dist/tools/healthCheck.js +230 -0
  61. package/dist/tools/killTask.d.ts +15 -0
  62. package/dist/tools/killTask.js +4 -0
  63. package/dist/tools/listAgents.d.ts +12 -0
  64. package/dist/tools/listAgents.js +42 -0
  65. package/dist/tools/listTasks.d.ts +36 -0
  66. package/dist/tools/listTasks.js +94 -0
  67. package/dist/tools/listWorkspace.d.ts +10 -0
  68. package/dist/tools/listWorkspace.js +54 -0
  69. package/dist/tools/readWorkspaceFile.d.ts +8 -0
  70. package/dist/tools/readWorkspaceFile.js +26 -0
  71. package/dist/tools/registry.d.ts +24 -0
  72. package/dist/tools/registry.js +566 -0
  73. package/dist/tools/retryTask.d.ts +8 -0
  74. package/dist/tools/retryTask.js +45 -0
  75. package/dist/tools/savePlan.d.ts +10 -0
  76. package/dist/tools/savePlan.js +28 -0
  77. package/dist/tools/taskOutputs.d.ts +49 -0
  78. package/dist/tools/taskOutputs.js +136 -0
  79. package/dist/tools/taskTemplates.d.ts +15 -0
  80. package/dist/tools/taskTemplates.js +116 -0
  81. package/dist/tools/toolCatalog.d.ts +19 -0
  82. package/dist/tools/toolCatalog.js +71 -0
  83. package/dist/tools/waitForTask.d.ts +25 -0
  84. package/dist/tools/waitForTask.js +59 -0
  85. package/dist/version.d.ts +2 -0
  86. package/dist/version.js +2 -0
  87. package/dist/watcherStatus.d.ts +20 -0
  88. package/dist/watcherStatus.js +77 -0
  89. package/docs/assets/patchwarden-chatgpt-demo.svg +30 -0
  90. package/docs/demo.md +58 -0
  91. package/docs/migration-from-safe-bifrost.md +31 -0
  92. package/docs/release-v0.3.0.md +43 -0
  93. package/docs/release-v0.4.0.md +74 -0
  94. package/examples/config.example.json +40 -0
  95. package/examples/openai-tunnel/README.md +48 -0
  96. package/examples/openai-tunnel/chatgpt-test-prompt.md +53 -0
  97. package/examples/openai-tunnel/tunnel-client.example.yaml +39 -0
  98. package/package.json +73 -0
  99. package/scripts/brand-check.js +44 -0
  100. package/scripts/doctor-smoke.js +52 -0
  101. package/scripts/get-patchwarden-health.ps1 +114 -0
  102. package/scripts/http-mcp-smoke.js +351 -0
  103. package/scripts/lifecycle-smoke.js +453 -0
  104. package/scripts/mcp-manifest-check.js +120 -0
  105. package/scripts/mcp-smoke.js +336 -0
  106. package/scripts/pack-clean.js +142 -0
  107. package/scripts/patchwarden-mcp-stdio.cmd +7 -0
  108. package/scripts/restart-patchwarden.ps1 +242 -0
  109. package/scripts/start-patchwarden-tunnel.ps1 +503 -0
  110. package/scripts/tunnel-supervisor-smoke.js +77 -0
  111. package/scripts/watcher-supervisor-smoke.js +195 -0
  112. package/src/config.ts +165 -0
  113. package/src/doctor.ts +438 -0
  114. package/src/errors.ts +26 -0
  115. package/src/httpServer.ts +277 -0
  116. package/src/index.ts +34 -0
  117. package/src/runner/changeCapture.ts +304 -0
  118. package/src/runner/cli.ts +34 -0
  119. package/src/runner/runTask.ts +935 -0
  120. package/src/runner/watch.ts +152 -0
  121. package/src/security/commandGuard.ts +97 -0
  122. package/src/security/contentRedaction.ts +85 -0
  123. package/src/security/pathGuard.ts +175 -0
  124. package/src/security/planGuard.ts +93 -0
  125. package/src/security/sensitiveGuard.ts +72 -0
  126. package/src/smoke-test.ts +1078 -0
  127. package/src/taskProgress.ts +41 -0
  128. package/src/taskRuntime.ts +37 -0
  129. package/src/tools/auditTask.ts +340 -0
  130. package/src/tools/cancelTask.ts +70 -0
  131. package/src/tools/createTask.ts +337 -0
  132. package/src/tools/getPlan.ts +48 -0
  133. package/src/tools/getTaskFile.ts +111 -0
  134. package/src/tools/getTaskProgress.ts +5 -0
  135. package/src/tools/getTaskStatus.ts +85 -0
  136. package/src/tools/getTaskStdoutTail.ts +61 -0
  137. package/src/tools/getTaskSummary.ts +307 -0
  138. package/src/tools/healthCheck.ts +234 -0
  139. package/src/tools/killTask.ts +5 -0
  140. package/src/tools/listAgents.ts +54 -0
  141. package/src/tools/listTasks.ts +139 -0
  142. package/src/tools/listWorkspace.ts +71 -0
  143. package/src/tools/readWorkspaceFile.ts +41 -0
  144. package/src/tools/registry.ts +666 -0
  145. package/src/tools/retryTask.ts +53 -0
  146. package/src/tools/savePlan.ts +46 -0
  147. package/src/tools/taskOutputs.ts +155 -0
  148. package/src/tools/taskTemplates.ts +162 -0
  149. package/src/tools/toolCatalog.ts +92 -0
  150. package/src/tools/waitForTask.ts +90 -0
  151. package/src/version.ts +2 -0
  152. package/src/watcherStatus.ts +102 -0
  153. package/tsconfig.json +17 -0
@@ -0,0 +1,503 @@
1
+ param(
2
+ [string]$TunnelId = $env:PATCHWARDEN_TUNNEL_ID,
3
+ [string]$Profile = "patchwarden",
4
+ [string]$ProxyUrl = $(if ($env:HTTPS_PROXY) { $env:HTTPS_PROXY } else { "http://127.0.0.1:7892" }),
5
+ [string]$TunnelClientExe = $env:TUNNEL_CLIENT_EXE,
6
+ [string]$OpencodeBin = $env:OPENCODE_BIN_DIR,
7
+ [string]$CredentialPath = $(if ($env:PATCHWARDEN_CREDENTIAL_PATH) { $env:PATCHWARDEN_CREDENTIAL_PATH } else { Join-Path $env:APPDATA "patchwarden\control-plane-api-key.dpapi" }),
8
+ [int]$ReconnectBaseSeconds = 5,
9
+ [int]$ReconnectMaxSeconds = 30,
10
+ [int]$UnreadyRestartSeconds = 90,
11
+ [int]$MaxReconnectAttempts = 0,
12
+ [int]$WatcherMaxRestartAttempts = 5,
13
+ [int]$WatcherHealthyResetSeconds = 60,
14
+ [switch]$SkipWatcher,
15
+ [switch]$ForgetSavedApiKey
16
+ )
17
+
18
+ $ErrorActionPreference = "Stop"
19
+
20
+ $ProjectRoot = Split-Path -Parent $PSScriptRoot
21
+ $ConfigPath = Join-Path $ProjectRoot "patchwarden.config.json"
22
+ $McpStdioLauncher = Join-Path $ProjectRoot "scripts\patchwarden-mcp-stdio.cmd"
23
+ $McpStdioLauncherForTunnel = $McpStdioLauncher -replace "\\", "/"
24
+ $OpencodeConfigHome = Join-Path $env:LOCALAPPDATA "patchwarden\opencode-config"
25
+ $ProfilePath = Join-Path $env:APPDATA "tunnel-client\$Profile.yaml"
26
+ $RuntimeDirectory = Join-Path $env:LOCALAPPDATA "patchwarden\runtime"
27
+ $StatusFile = Join-Path $RuntimeDirectory "tunnel-status.json"
28
+ $HealthUrlFile = Join-Path $RuntimeDirectory "tunnel-health-url.txt"
29
+ $PidFile = Join-Path $RuntimeDirectory "tunnel-client.pid"
30
+ $WatcherStatusFile = Join-Path $RuntimeDirectory "watcher-status.json"
31
+ $script:PendingCredential = $null
32
+ $script:TunnelProcess = $null
33
+ $script:ToolManifest = $null
34
+ $script:WatcherProcess = $null
35
+ $script:WatcherInstanceId = $null
36
+ $script:WatcherManaged = $false
37
+ $script:WatcherRestartAttempts = 0
38
+ $script:WatcherHealthySince = $null
39
+ $script:WatcherRestartExhausted = $false
40
+
41
+ function Assert-File {
42
+ param([string]$Path, [string]$Name)
43
+ if (-not (Test-Path -LiteralPath $Path)) {
44
+ throw "$Name not found: $Path"
45
+ }
46
+ }
47
+
48
+ function Set-SecretEnvIfMissing {
49
+ if ($env:CONTROL_PLANE_API_KEY) {
50
+ Write-Host "[ok] CONTROL_PLANE_API_KEY is already set in this PowerShell process."
51
+ return
52
+ }
53
+
54
+ if (Test-Path -LiteralPath $CredentialPath) {
55
+ try {
56
+ $encrypted = Get-Content -LiteralPath $CredentialPath -Raw
57
+ $secure = ConvertTo-SecureString $encrypted
58
+ $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
59
+ try {
60
+ $env:CONTROL_PLANE_API_KEY = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
61
+ } finally {
62
+ if ($bstr -ne [IntPtr]::Zero) {
63
+ [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
64
+ }
65
+ }
66
+ if (-not $env:CONTROL_PLANE_API_KEY) {
67
+ throw "Saved credential decrypted to an empty value."
68
+ }
69
+ Write-Host "[ok] Loaded tunnel runtime API key from Windows DPAPI credential cache."
70
+ return
71
+ } catch {
72
+ throw "Could not decrypt saved tunnel API key at $CredentialPath. Run this script with -ForgetSavedApiKey, then start it again."
73
+ }
74
+ }
75
+
76
+ Write-Host "[input] Paste your OpenAI tunnel runtime API key."
77
+ Write-Host " It will be encrypted with Windows DPAPI for this user and computer."
78
+ $secure = Read-Host "CONTROL_PLANE_API_KEY" -AsSecureString
79
+ $script:PendingCredential = $secure
80
+
81
+ $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
82
+ try {
83
+ $env:CONTROL_PLANE_API_KEY = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
84
+ } finally {
85
+ if ($bstr -ne [IntPtr]::Zero) {
86
+ [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
87
+ }
88
+ }
89
+
90
+ if (-not $env:CONTROL_PLANE_API_KEY) {
91
+ throw "CONTROL_PLANE_API_KEY was empty."
92
+ }
93
+ }
94
+
95
+ function Save-PendingCredential {
96
+ if ($null -eq $script:PendingCredential) {
97
+ return
98
+ }
99
+ $credentialDirectory = Split-Path -Parent $CredentialPath
100
+ New-Item -ItemType Directory -Force -Path $credentialDirectory | Out-Null
101
+ $encrypted = ConvertFrom-SecureString $script:PendingCredential
102
+ Set-Content -LiteralPath $CredentialPath -Value $encrypted -Encoding UTF8 -NoNewline
103
+ $script:PendingCredential = $null
104
+ Write-Host "[saved] Encrypted credential cache: $CredentialPath"
105
+ }
106
+
107
+ function Write-TunnelStatus {
108
+ param(
109
+ [string]$Status,
110
+ [string]$ReasonCode = $null,
111
+ [bool]$Ready = $false,
112
+ [int]$Attempt = 0,
113
+ [Nullable[int]]$ProcessId = $null,
114
+ [string]$LastError = $null,
115
+ [string]$NextRetryAt = $null
116
+ )
117
+ 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 }
119
+ $payload = [ordered]@{
120
+ status = $Status
121
+ reason_code = $ReasonCode
122
+ ready = $Ready
123
+ attempt = $Attempt
124
+ pid = $ProcessId
125
+ checked_at = (Get-Date).ToUniversalTime().ToString("o")
126
+ next_retry_at = $NextRetryAt
127
+ last_error = $safeError
128
+ server_version = if ($script:ToolManifest) { $script:ToolManifest.server_version } else { $null }
129
+ schema_epoch = if ($script:ToolManifest) { $script:ToolManifest.schema_epoch } else { $null }
130
+ tool_profile = if ($script:ToolManifest) { $script:ToolManifest.tool_profile } else { $null }
131
+ tool_count = if ($script:ToolManifest) { $script:ToolManifest.tool_count } else { $null }
132
+ tool_names = if ($script:ToolManifest) { $script:ToolManifest.tool_names } else { @() }
133
+ tool_manifest_sha256 = if ($script:ToolManifest) { $script:ToolManifest.tool_manifest_sha256 } else { $null }
134
+ core_tools_ready = [bool]($script:ToolManifest -and $script:ToolManifest.ok)
135
+ }
136
+ $temporary = "$StatusFile.tmp"
137
+ $payload | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $temporary -Encoding UTF8
138
+ Move-Item -LiteralPath $temporary -Destination $StatusFile -Force
139
+ }
140
+
141
+ function Get-DiagnosticCode {
142
+ param([string]$Text)
143
+ if ($Text -match '(?i)unsupported_country_region_territory|unsupported.{0,20}(country|region|territory)') { return "unsupported_region" }
144
+ if ($Text -match '(?i)401|403|unauthori[sz]ed|invalid.{0,20}(api.?key|credential)|api.?key.{0,20}(invalid|missing)') { return "auth_failed" }
145
+ if ($Text -match '(?i)404|not found') { return "control_plane_not_found" }
146
+ if ($Text -match '(?i)profile.{0,30}(missing|invalid)|config.{0,30}(missing|invalid)|mcp.{0,30}command.{0,30}(missing|not found)') { return "config_error" }
147
+ return "transient_connection_failure"
148
+ }
149
+
150
+ function Invoke-TunnelDoctor {
151
+ $output = (& $TunnelClientExe doctor --profile $Profile --explain --json --http-proxy env:HTTPS_PROXY 2>&1 | Out-String)
152
+ return [pscustomobject]@{
153
+ ExitCode = $LASTEXITCODE
154
+ Output = $output
155
+ ReasonCode = Get-DiagnosticCode $output
156
+ }
157
+ }
158
+
159
+ function Get-WatcherSettings {
160
+ $cfg = Get-Content -LiteralPath $ConfigPath -Raw | ConvertFrom-Json
161
+ $tasks = Join-Path $cfg.workspaceRoot $cfg.tasksDir
162
+ return [pscustomobject]@{
163
+ HeartbeatPath = Join-Path (Split-Path -Parent $tasks) "watcher-heartbeat.json"
164
+ StaleSeconds = if ($cfg.watcherStaleSeconds) { [int]$cfg.watcherStaleSeconds } else { 30 }
165
+ }
166
+ }
167
+
168
+ function Get-WatcherHeartbeatState {
169
+ param([string]$ExpectedInstanceId = $null)
170
+ try {
171
+ $settings = Get-WatcherSettings
172
+ if (-not (Test-Path -LiteralPath $settings.HeartbeatPath)) {
173
+ return [pscustomobject]@{ Fresh = $false; Status = "missing"; AgeSeconds = $null; Data = $null }
174
+ }
175
+ $data = Get-Content -LiteralPath $settings.HeartbeatPath -Raw | ConvertFrom-Json
176
+ $age = (Get-Date).ToUniversalTime() - ([DateTime]::Parse($data.last_heartbeat_at).ToUniversalTime())
177
+ $instanceMatches = -not $ExpectedInstanceId -or [string]$data.instance_id -eq $ExpectedInstanceId
178
+ $fresh = $age.TotalSeconds -lt $settings.StaleSeconds -and $instanceMatches
179
+ return [pscustomobject]@{
180
+ Fresh = $fresh
181
+ Status = if (-not $instanceMatches) { "instance_mismatch" } elseif ($fresh) { "healthy" } else { "stale" }
182
+ AgeSeconds = [Math]::Max(0, [Math]::Round($age.TotalSeconds))
183
+ Data = $data
184
+ }
185
+ } catch {
186
+ return [pscustomobject]@{ Fresh = $false; Status = "unreadable"; AgeSeconds = $null; Data = $null }
187
+ }
188
+ }
189
+
190
+ function Write-WatcherStatus {
191
+ param([string]$Status, [string]$LastError = $null)
192
+ New-Item -ItemType Directory -Force -Path $RuntimeDirectory | Out-Null
193
+ $payload = [ordered]@{
194
+ managed = $script:WatcherManaged
195
+ status = $Status
196
+ pid = if ($script:WatcherProcess -and -not $script:WatcherProcess.HasExited) { $script:WatcherProcess.Id } else { $null }
197
+ instance_id = $script:WatcherInstanceId
198
+ launcher_pid = $PID
199
+ restart_attempts = $script:WatcherRestartAttempts
200
+ checked_at = (Get-Date).ToUniversalTime().ToString("o")
201
+ last_error = if ($LastError) { ($LastError -replace '[\r\n]+', ' ').Substring(0, [Math]::Min(500, ($LastError -replace '[\r\n]+', ' ').Length)) } else { $null }
202
+ }
203
+ $temporary = "$WatcherStatusFile.tmp"
204
+ $payload | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $temporary -Encoding UTF8
205
+ Move-Item -LiteralPath $temporary -Destination $WatcherStatusFile -Force
206
+ }
207
+
208
+ function Stop-OwnedWatcherProcess {
209
+ if ($script:WatcherManaged -and $script:WatcherProcess -and -not $script:WatcherProcess.HasExited) {
210
+ Stop-Process -Id $script:WatcherProcess.Id -ErrorAction SilentlyContinue
211
+ try { $script:WatcherProcess.WaitForExit(5000) | Out-Null } catch {}
212
+ }
213
+ }
214
+
215
+ function Start-OwnedWatcherProcess {
216
+ $script:WatcherInstanceId = [Guid]::NewGuid().ToString("n")
217
+ $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
+ $node = (Get-Command node.exe -ErrorAction Stop).Source
223
+ $stdout = Join-Path $RuntimeDirectory "watcher-$($script:WatcherInstanceId).stdout.log"
224
+ $stderr = Join-Path $RuntimeDirectory "watcher-$($script:WatcherInstanceId).stderr.log"
225
+ $script:WatcherManaged = $true
226
+ $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
231
+ Write-WatcherStatus -Status "starting"
232
+ Write-Host "[watch] Started owned watcher PID $($script:WatcherProcess.Id), instance $($script:WatcherInstanceId)."
233
+ }
234
+
235
+ function Start-PatchWardenWatcher {
236
+ $existing = Get-WatcherHeartbeatState
237
+ if ($existing.Fresh) {
238
+ $script:WatcherManaged = $false
239
+ Write-WatcherStatus -Status "external_healthy"
240
+ Write-Host "[watch] Existing watcher heartbeat is fresh; it remains external and will not be managed."
241
+ return
242
+ }
243
+ Start-OwnedWatcherProcess
244
+ }
245
+
246
+ function Invoke-WatcherSupervisorTick {
247
+ if (-not $script:WatcherManaged -or $script:WatcherRestartExhausted) { return }
248
+ $heartbeat = Get-WatcherHeartbeatState -ExpectedInstanceId $script:WatcherInstanceId
249
+ $processAlive = $script:WatcherProcess -and -not $script:WatcherProcess.HasExited
250
+ if ($processAlive -and $heartbeat.Fresh) {
251
+ if (-not $script:WatcherHealthySince) { $script:WatcherHealthySince = Get-Date }
252
+ if (((Get-Date) - $script:WatcherHealthySince).TotalSeconds -ge $WatcherHealthyResetSeconds) {
253
+ $script:WatcherRestartAttempts = 0
254
+ }
255
+ Write-WatcherStatus -Status "healthy"
256
+ return
257
+ }
258
+
259
+ $failure = if (-not $processAlive) { "Owned watcher process exited." } else { "Owned watcher heartbeat is $($heartbeat.Status)." }
260
+ Stop-OwnedWatcherProcess
261
+ $script:WatcherRestartAttempts++
262
+ if ($script:WatcherRestartAttempts -gt $WatcherMaxRestartAttempts) {
263
+ $script:WatcherRestartExhausted = $true
264
+ Write-WatcherStatus -Status "restart_limit_reached" -LastError $failure
265
+ Write-Host "[watch] Restart limit reached; tunnel stays running but watcher is degraded."
266
+ return
267
+ }
268
+ $delays = @(2, 5, 10, 20, 30)
269
+ $delay = $delays[[Math]::Min($script:WatcherRestartAttempts - 1, $delays.Count - 1)]
270
+ Write-WatcherStatus -Status "restarting" -LastError $failure
271
+ Write-Host "[watch] $failure Restarting owned watcher in $delay seconds (attempt $($script:WatcherRestartAttempts)/$WatcherMaxRestartAttempts)."
272
+ Start-Sleep -Seconds $delay
273
+ Start-OwnedWatcherProcess
274
+ }
275
+
276
+ function Get-TunnelHealth {
277
+ if (-not (Test-Path -LiteralPath $HealthUrlFile) -or -not (Test-Path -LiteralPath $PidFile)) { return $null }
278
+ $output = (& $TunnelClientExe health --json --url-file $HealthUrlFile --pid-file $PidFile 2>&1 | Out-String)
279
+ try { return $output | ConvertFrom-Json } catch { return $null }
280
+ }
281
+
282
+ function Stop-OwnedTunnelProcess {
283
+ if ($script:TunnelProcess -and -not $script:TunnelProcess.HasExited) {
284
+ Stop-Process -Id $script:TunnelProcess.Id -ErrorAction SilentlyContinue
285
+ try { $script:TunnelProcess.WaitForExit(5000) | Out-Null } catch {}
286
+ }
287
+ }
288
+
289
+ function Quote-ProcessArgument {
290
+ param([string]$Value)
291
+ return '"' + ($Value -replace '"', '\"') + '"'
292
+ }
293
+
294
+ if ($ForgetSavedApiKey) {
295
+ if (Test-Path -LiteralPath $CredentialPath) {
296
+ Remove-Item -LiteralPath $CredentialPath -Force
297
+ Write-Host "[ok] Removed saved PatchWarden tunnel API key."
298
+ } else {
299
+ Write-Host "[ok] No saved PatchWarden tunnel API key was found."
300
+ }
301
+ exit 0
302
+ }
303
+
304
+ if (-not $TunnelClientExe) {
305
+ $cmd = Get-Command "tunnel-client.exe" -ErrorAction SilentlyContinue
306
+ if ($cmd) {
307
+ $TunnelClientExe = $cmd.Source
308
+ }
309
+ }
310
+
311
+ if (-not $TunnelClientExe) {
312
+ $TunnelClientExe = Read-Host "Path to tunnel-client.exe"
313
+ }
314
+
315
+ if (-not $OpencodeBin) {
316
+ $candidateOpencodeBin = Join-Path $env:APPDATA "npm\node_modules\opencode-ai\bin"
317
+ if (Test-Path -LiteralPath $candidateOpencodeBin) {
318
+ $OpencodeBin = $candidateOpencodeBin
319
+ }
320
+ }
321
+
322
+ if (-not $OpencodeBin) {
323
+ Write-Host "[warn] OPENCODE_BIN_DIR is not set and opencode-ai bin was not found under APPDATA."
324
+ Write-Host " Watcher will still start, but opencode tasks may fail unless opencode is on PATH."
325
+ }
326
+
327
+ Assert-File -Path $TunnelClientExe -Name "tunnel-client.exe"
328
+ Assert-File -Path $ConfigPath -Name "patchwarden.config.json"
329
+ Assert-File -Path $McpStdioLauncher -Name "patchwarden-mcp-stdio.cmd"
330
+
331
+ if (-not $TunnelId) {
332
+ $TunnelId = Read-Host "Tunnel ID"
333
+ }
334
+ if (-not $TunnelId) {
335
+ throw "Tunnel ID was empty."
336
+ }
337
+
338
+ Set-Location -LiteralPath $ProjectRoot
339
+
340
+ if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot "dist\index.js"))) {
341
+ Write-Host "[build] dist/index.js not found. Running npm.cmd run build..."
342
+ npm.cmd run build
343
+ }
344
+
345
+ New-Item -ItemType Directory -Force -Path $RuntimeDirectory | Out-Null
346
+ $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()
349
+ if ($LASTEXITCODE -ne 0) {
350
+ Write-TunnelStatus -Status "stopped" -ReasonCode "tool_manifest_check_failed" -LastError "The tunnel MCP tool manifest preflight failed."
351
+ throw "Tool manifest preflight failed: $manifestOutput"
352
+ }
353
+ try {
354
+ $script:ToolManifest = $manifestOutput | ConvertFrom-Json
355
+ } catch {
356
+ Write-TunnelStatus -Status "stopped" -ReasonCode "tool_manifest_invalid" -LastError "The tool manifest preflight returned invalid JSON."
357
+ throw "Tool manifest preflight returned invalid JSON."
358
+ }
359
+ $manifestFile = Join-Path $RuntimeDirectory "tool-manifest.json"
360
+ $manifestOutput | Set-Content -LiteralPath $manifestFile -Encoding UTF8
361
+ Write-Host "[manifest] $($script:ToolManifest.server_version) profile=$($script:ToolManifest.tool_profile) tools=$($script:ToolManifest.tool_count) hash=$($script:ToolManifest.tool_manifest_sha256)"
362
+
363
+ Set-SecretEnvIfMissing
364
+
365
+ $env:PATCHWARDEN_CONFIG = $ConfigPath
366
+ $env:HTTP_PROXY = $ProxyUrl
367
+ $env:HTTPS_PROXY = $ProxyUrl
368
+ $env:ALL_PROXY = $ProxyUrl
369
+ $env:NO_PROXY = "localhost,127.0.0.1,::1"
370
+
371
+ $profileNeedsInit = $true
372
+ if (Test-Path -LiteralPath $ProfilePath) {
373
+ $profileText = Get-Content -LiteralPath $ProfilePath -Raw
374
+ $profileNeedsInit = -not $profileText.Contains($McpStdioLauncherForTunnel)
375
+ }
376
+
377
+ if ($profileNeedsInit) {
378
+ Write-Host "[init] Creating tunnel-client profile: $Profile"
379
+ & $TunnelClientExe init `
380
+ --sample sample_mcp_stdio_local `
381
+ --profile $Profile `
382
+ --tunnel-id $TunnelId `
383
+ --mcp-command $McpStdioLauncherForTunnel `
384
+ --force
385
+ }
386
+
387
+ $env:PATCHWARDEN_TUNNEL_STATUS_FILE = $StatusFile
388
+ if (-not $SkipWatcher) { Start-PatchWardenWatcher }
389
+
390
+ Write-Host "[doctor] Checking tunnel-client profile through the configured proxy..."
391
+ $preflightAttempt = 0
392
+ $preflightMaxRetries = 3
393
+ $doctor = Invoke-TunnelDoctor
394
+ while ($doctor.ExitCode -ne 0 -and $doctor.ReasonCode -eq "transient_connection_failure" -and $preflightAttempt -lt $preflightMaxRetries) {
395
+ $preflightAttempt++
396
+ $backoff = [Math]::Min($ReconnectBaseSeconds * $preflightAttempt, $ReconnectMaxSeconds)
397
+ Write-Host "[doctor] Transient connection failure; retrying in ${backoff}s (attempt $preflightAttempt/$preflightMaxRetries)..." -ForegroundColor Yellow
398
+ Start-Sleep -Seconds $backoff
399
+ $doctor = Invoke-TunnelDoctor
400
+ }
401
+ if ($doctor.ExitCode -ne 0) {
402
+ 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
404
+ Write-TunnelStatus -Status "degraded" -ReasonCode $doctor.ReasonCode -Ready $false -LastError "Preflight doctor reported transient connection failure; proceeding with supervisor-managed recovery."
405
+ } else {
406
+ Write-TunnelStatus -Status "stopped" -ReasonCode $doctor.ReasonCode -LastError "tunnel-client doctor failed; review the launcher output."
407
+ Write-Host "[doctor] Non-retryable diagnostic: $($doctor.ReasonCode)" -ForegroundColor Red
408
+ if ($null -ne $script:PendingCredential) {
409
+ throw "tunnel-client doctor failed ($($doctor.ReasonCode)). The newly entered API key was not saved."
410
+ } else {
411
+ throw "tunnel-client doctor failed ($($doctor.ReasonCode)). The saved API key is unchanged; review proxy/region settings and retry."
412
+ }
413
+ }
414
+ }
415
+ Save-PendingCredential
416
+
417
+ Write-Host ""
418
+ 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."
420
+ Write-Host ""
421
+
422
+ $attempt = 0
423
+ $openUi = $true
424
+ try {
425
+ while ($true) {
426
+ $attempt++
427
+ Remove-Item -LiteralPath $HealthUrlFile, $PidFile -Force -ErrorAction SilentlyContinue
428
+ Write-TunnelStatus -Status "starting" -ReasonCode $null -Ready $false -Attempt $attempt
429
+ $runArguments = @(
430
+ "run", "--profile", $Profile,
431
+ "--http-proxy", "env:HTTPS_PROXY",
432
+ "--health.url-file", $HealthUrlFile,
433
+ "--pid.file", $PidFile,
434
+ "--log.format", "json"
435
+ )
436
+ if ($openUi) { $runArguments += "--open-web-ui"; $openUi = $false }
437
+ $argumentLine = ($runArguments | ForEach-Object { Quote-ProcessArgument $_ }) -join " "
438
+ $script:TunnelProcess = Start-Process -FilePath $TunnelClientExe -ArgumentList $argumentLine -PassThru -WindowStyle Hidden
439
+ 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"
441
+
442
+ $unreadySince = $null
443
+ $restartForHealth = $false
444
+ while (-not $script:TunnelProcess.HasExited) {
445
+ Start-Sleep -Seconds 5
446
+ if (-not $SkipWatcher) { Invoke-WatcherSupervisorTick }
447
+ $health = Get-TunnelHealth
448
+ if ($health -and $health.healthz.ok -and $health.readyz.ok) {
449
+ $unreadySince = $null
450
+ Write-TunnelStatus -Status "ready" -ReasonCode $null -Ready $true -Attempt $attempt -ProcessId $script:TunnelProcess.Id
451
+ continue
452
+ }
453
+ 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."
455
+ if (((Get-Date) - $unreadySince).TotalSeconds -ge $UnreadyRestartSeconds) {
456
+ $doctor = Invoke-TunnelDoctor
457
+ if ($doctor.ExitCode -ne 0 -and $doctor.ReasonCode -ne "transient_connection_failure") {
458
+ Stop-OwnedTunnelProcess
459
+ Write-TunnelStatus -Status "stopped" -ReasonCode $doctor.ReasonCode -Ready $false -Attempt $attempt -LastError "Tunnel readiness failed and doctor reported a non-retryable error."
460
+ Write-Host "[doctor] Non-retryable diagnostic: $($doctor.ReasonCode)"
461
+ throw "Tunnel stopped after non-retryable readiness failure: $($doctor.ReasonCode)"
462
+ }
463
+ Write-Host "[recover] Tunnel stayed unready for $UnreadyRestartSeconds seconds; restarting the owned process."
464
+ $restartForHealth = $true
465
+ Stop-OwnedTunnelProcess
466
+ }
467
+ }
468
+
469
+ $exitCode = $script:TunnelProcess.ExitCode
470
+ $doctor = Invoke-TunnelDoctor
471
+ 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."
473
+ Write-Host "[doctor] Non-retryable diagnostic: $($doctor.ReasonCode)"
474
+ throw "Tunnel stopped after non-retryable error: $($doctor.ReasonCode)"
475
+ }
476
+ 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."
478
+ throw "Tunnel reconnect attempt limit reached."
479
+ }
480
+ $delay = [Math]::Min($ReconnectMaxSeconds, $ReconnectBaseSeconds * [Math]::Pow(2, [Math]::Min(3, $attempt - 1)))
481
+ $nextRetry = (Get-Date).AddSeconds($delay).ToUniversalTime().ToString("o")
482
+ $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
484
+ Write-Host "[retry] tunnel-client exited with code $exitCode; retrying in $delay seconds."
485
+ Start-Sleep -Seconds $delay
486
+ }
487
+ } finally {
488
+ Stop-OwnedTunnelProcess
489
+ Stop-OwnedWatcherProcess
490
+ if ($script:WatcherManaged -and -not $script:WatcherRestartExhausted) {
491
+ Write-WatcherStatus -Status "stopped" -LastError "Tunnel launcher stopped its owned watcher."
492
+ }
493
+ $alreadyStopped = $false
494
+ try {
495
+ if (Test-Path -LiteralPath $StatusFile) {
496
+ $existingStatus = Get-Content -LiteralPath $StatusFile -Raw | ConvertFrom-Json
497
+ $alreadyStopped = $existingStatus.status -eq "stopped"
498
+ }
499
+ } catch {}
500
+ if (-not $alreadyStopped) {
501
+ Write-TunnelStatus -Status "stopped" -ReasonCode "launcher_stopped" -Ready $false -Attempt $attempt -LastError "Tunnel launcher stopped."
502
+ }
503
+ }
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ if (process.platform !== "win32") {
9
+ console.log("ok - tunnel supervisor smoke skipped outside Windows");
10
+ process.exit(0);
11
+ }
12
+
13
+ const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
14
+ const temp = mkdtempSync(join(tmpdir(), "patchwarden-tunnel-smoke-"));
15
+ const mockJs = join(temp, "mock-tunnel-client.js");
16
+ const mockCmd = join(temp, "mock-tunnel-client.cmd");
17
+ const stateFile = join(temp, "attempt.txt");
18
+ const secretMarker = "smoke-secret-must-not-appear";
19
+
20
+ try {
21
+ writeFileSync(mockCmd, `@echo off\r\nnode "%MOCK_TUNNEL_JS%" %*\r\n`, "utf-8");
22
+ writeFileSync(mockJs, `
23
+ const fs=require('fs');
24
+ const http=require('http');
25
+ const args=process.argv.slice(2);
26
+ const command=args[0]||'';
27
+ const flag=(name)=>{const i=args.indexOf(name);return i>=0?args[i+1]:''};
28
+ if(command==='init'||command==='doctor'){console.log(JSON.stringify({result:'ok'}));process.exit(0)}
29
+ if(command==='health'){console.log(JSON.stringify({healthz:{ok:true},readyz:{ok:true},result:'ok'}));process.exit(0)}
30
+ if(command==='run'){
31
+ let attempt=0;try{attempt=Number(fs.readFileSync(process.env.MOCK_TUNNEL_STATE,'utf8'))||0}catch{}
32
+ attempt++;fs.writeFileSync(process.env.MOCK_TUNNEL_STATE,String(attempt));
33
+ if(attempt===1)process.exit(7);
34
+ const urlFile=flag('--health.url-file');const pidFile=flag('--pid.file');
35
+ const server=http.createServer((req,res)=>{res.writeHead(200,{'content-type':'application/json'});res.end(JSON.stringify({ok:true}))});
36
+ 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);
38
+ }
39
+ `, "utf-8");
40
+
41
+ const env = {
42
+ ...process.env,
43
+ APPDATA: join(temp, "appdata"),
44
+ LOCALAPPDATA: join(temp, "localappdata"),
45
+ CONTROL_PLANE_API_KEY: secretMarker,
46
+ MOCK_TUNNEL_JS: mockJs,
47
+ MOCK_TUNNEL_STATE: stateFile,
48
+ };
49
+ const result = spawnSync("powershell.exe", [
50
+ "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", join(root, "scripts", "start-patchwarden-tunnel.ps1"),
51
+ "-TunnelId", "tunnel_smoke_fixture",
52
+ "-TunnelClientExe", mockCmd,
53
+ "-ProxyUrl", "http://127.0.0.1:1",
54
+ "-ReconnectBaseSeconds", "1",
55
+ "-ReconnectMaxSeconds", "1",
56
+ "-UnreadyRestartSeconds", "20",
57
+ "-MaxReconnectAttempts", "2",
58
+ "-SkipWatcher",
59
+ ], { cwd: root, env, encoding: "utf-8", timeout: 30_000 });
60
+
61
+ if (result.error) throw result.error;
62
+ if (!existsSync(stateFile) || readFileSync(stateFile, "utf-8").trim() !== "2") {
63
+ throw new Error(`Expected two supervised attempts. stdout=${result.stdout} stderr=${result.stderr}`);
64
+ }
65
+ const statusPath = join(env.LOCALAPPDATA, "patchwarden", "runtime", "tunnel-status.json");
66
+ const status = JSON.parse(readFileSync(statusPath, "utf-8").replace(/^\uFEFF/, ""));
67
+ if (status.reason_code !== "retry_limit_reached" || status.attempt !== 2) {
68
+ throw new Error(`Unexpected final supervisor status: ${JSON.stringify(status)}`);
69
+ }
70
+ const serialized = JSON.stringify(status);
71
+ if (serialized.includes(secretMarker) || serialized.includes("tunnel_smoke_fixture")) {
72
+ throw new Error("Supervisor status leaked credential or tunnel identifier material");
73
+ }
74
+ console.log("ok - tunnel supervisor retries, probes readiness, and writes redacted status");
75
+ } finally {
76
+ rmSync(temp, { recursive: true, force: true });
77
+ }