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.
- package/Check-PatchWarden-Health.cmd +6 -0
- package/LICENSE +21 -0
- package/README.md +526 -0
- package/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
- package/Restart-PatchWarden.cmd +19 -0
- package/Start-PatchWarden-Tunnel.cmd +7 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.js +131 -0
- package/dist/doctor.d.ts +10 -0
- package/dist/doctor.js +388 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +27 -0
- package/dist/httpServer.d.ts +17 -0
- package/dist/httpServer.js +255 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +24 -0
- package/dist/runner/changeCapture.d.ts +43 -0
- package/dist/runner/changeCapture.js +245 -0
- package/dist/runner/cli.d.ts +9 -0
- package/dist/runner/cli.js +26 -0
- package/dist/runner/runTask.d.ts +8 -0
- package/dist/runner/runTask.js +821 -0
- package/dist/runner/watch.d.ts +19 -0
- package/dist/runner/watch.js +136 -0
- package/dist/security/commandGuard.d.ts +22 -0
- package/dist/security/commandGuard.js +56 -0
- package/dist/security/contentRedaction.d.ts +12 -0
- package/dist/security/contentRedaction.js +65 -0
- package/dist/security/pathGuard.d.ts +3 -0
- package/dist/security/pathGuard.js +98 -0
- package/dist/security/planGuard.d.ts +1 -0
- package/dist/security/planGuard.js +65 -0
- package/dist/security/sensitiveGuard.d.ts +2 -0
- package/dist/security/sensitiveGuard.js +60 -0
- package/dist/smoke-test.d.ts +15 -0
- package/dist/smoke-test.js +989 -0
- package/dist/taskProgress.d.ts +6 -0
- package/dist/taskProgress.js +36 -0
- package/dist/taskRuntime.d.ts +10 -0
- package/dist/taskRuntime.js +25 -0
- package/dist/tools/auditTask.d.ts +19 -0
- package/dist/tools/auditTask.js +307 -0
- package/dist/tools/cancelTask.d.ts +30 -0
- package/dist/tools/cancelTask.js +64 -0
- package/dist/tools/createTask.d.ts +42 -0
- package/dist/tools/createTask.js +188 -0
- package/dist/tools/getPlan.d.ts +12 -0
- package/dist/tools/getPlan.js +29 -0
- package/dist/tools/getTaskFile.d.ts +24 -0
- package/dist/tools/getTaskFile.js +84 -0
- package/dist/tools/getTaskProgress.d.ts +1 -0
- package/dist/tools/getTaskProgress.js +4 -0
- package/dist/tools/getTaskStatus.d.ts +46 -0
- package/dist/tools/getTaskStatus.js +36 -0
- package/dist/tools/getTaskStdoutTail.d.ts +9 -0
- package/dist/tools/getTaskStdoutTail.js +56 -0
- package/dist/tools/getTaskSummary.d.ts +66 -0
- package/dist/tools/getTaskSummary.js +237 -0
- package/dist/tools/healthCheck.d.ts +79 -0
- package/dist/tools/healthCheck.js +230 -0
- package/dist/tools/killTask.d.ts +15 -0
- package/dist/tools/killTask.js +4 -0
- package/dist/tools/listAgents.d.ts +12 -0
- package/dist/tools/listAgents.js +42 -0
- package/dist/tools/listTasks.d.ts +36 -0
- package/dist/tools/listTasks.js +94 -0
- package/dist/tools/listWorkspace.d.ts +10 -0
- package/dist/tools/listWorkspace.js +54 -0
- package/dist/tools/readWorkspaceFile.d.ts +8 -0
- package/dist/tools/readWorkspaceFile.js +26 -0
- package/dist/tools/registry.d.ts +24 -0
- package/dist/tools/registry.js +566 -0
- package/dist/tools/retryTask.d.ts +8 -0
- package/dist/tools/retryTask.js +45 -0
- package/dist/tools/savePlan.d.ts +10 -0
- package/dist/tools/savePlan.js +28 -0
- package/dist/tools/taskOutputs.d.ts +49 -0
- package/dist/tools/taskOutputs.js +136 -0
- package/dist/tools/taskTemplates.d.ts +15 -0
- package/dist/tools/taskTemplates.js +116 -0
- package/dist/tools/toolCatalog.d.ts +19 -0
- package/dist/tools/toolCatalog.js +71 -0
- package/dist/tools/waitForTask.d.ts +25 -0
- package/dist/tools/waitForTask.js +59 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/watcherStatus.d.ts +20 -0
- package/dist/watcherStatus.js +77 -0
- package/docs/assets/patchwarden-chatgpt-demo.svg +30 -0
- package/docs/demo.md +58 -0
- package/docs/migration-from-safe-bifrost.md +31 -0
- package/docs/release-v0.3.0.md +43 -0
- package/docs/release-v0.4.0.md +74 -0
- package/examples/config.example.json +40 -0
- package/examples/openai-tunnel/README.md +48 -0
- package/examples/openai-tunnel/chatgpt-test-prompt.md +53 -0
- package/examples/openai-tunnel/tunnel-client.example.yaml +39 -0
- package/package.json +73 -0
- package/scripts/brand-check.js +44 -0
- package/scripts/doctor-smoke.js +52 -0
- package/scripts/get-patchwarden-health.ps1 +114 -0
- package/scripts/http-mcp-smoke.js +351 -0
- package/scripts/lifecycle-smoke.js +453 -0
- package/scripts/mcp-manifest-check.js +120 -0
- package/scripts/mcp-smoke.js +336 -0
- package/scripts/pack-clean.js +142 -0
- package/scripts/patchwarden-mcp-stdio.cmd +7 -0
- package/scripts/restart-patchwarden.ps1 +242 -0
- package/scripts/start-patchwarden-tunnel.ps1 +503 -0
- package/scripts/tunnel-supervisor-smoke.js +77 -0
- package/scripts/watcher-supervisor-smoke.js +195 -0
- package/src/config.ts +165 -0
- package/src/doctor.ts +438 -0
- package/src/errors.ts +26 -0
- package/src/httpServer.ts +277 -0
- package/src/index.ts +34 -0
- package/src/runner/changeCapture.ts +304 -0
- package/src/runner/cli.ts +34 -0
- package/src/runner/runTask.ts +935 -0
- package/src/runner/watch.ts +152 -0
- package/src/security/commandGuard.ts +97 -0
- package/src/security/contentRedaction.ts +85 -0
- package/src/security/pathGuard.ts +175 -0
- package/src/security/planGuard.ts +93 -0
- package/src/security/sensitiveGuard.ts +72 -0
- package/src/smoke-test.ts +1078 -0
- package/src/taskProgress.ts +41 -0
- package/src/taskRuntime.ts +37 -0
- package/src/tools/auditTask.ts +340 -0
- package/src/tools/cancelTask.ts +70 -0
- package/src/tools/createTask.ts +337 -0
- package/src/tools/getPlan.ts +48 -0
- package/src/tools/getTaskFile.ts +111 -0
- package/src/tools/getTaskProgress.ts +5 -0
- package/src/tools/getTaskStatus.ts +85 -0
- package/src/tools/getTaskStdoutTail.ts +61 -0
- package/src/tools/getTaskSummary.ts +307 -0
- package/src/tools/healthCheck.ts +234 -0
- package/src/tools/killTask.ts +5 -0
- package/src/tools/listAgents.ts +54 -0
- package/src/tools/listTasks.ts +139 -0
- package/src/tools/listWorkspace.ts +71 -0
- package/src/tools/readWorkspaceFile.ts +41 -0
- package/src/tools/registry.ts +666 -0
- package/src/tools/retryTask.ts +53 -0
- package/src/tools/savePlan.ts +46 -0
- package/src/tools/taskOutputs.ts +155 -0
- package/src/tools/taskTemplates.ts +162 -0
- package/src/tools/toolCatalog.ts +92 -0
- package/src/tools/waitForTask.ts +90 -0
- package/src/version.ts +2 -0
- package/src/watcherStatus.ts +102 -0
- 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
|
+
}
|