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.
- package/PatchWarden.cmd +51 -0
- package/README.en.md +1022 -0
- package/README.md +897 -358
- package/dist/assessments/agentAssessor.d.ts +15 -0
- package/dist/assessments/agentAssessor.js +293 -0
- package/dist/assessments/assessmentStore.d.ts +133 -0
- package/dist/assessments/assessmentStore.js +238 -0
- package/dist/assessments/confirmCli.d.ts +9 -0
- package/dist/assessments/confirmCli.js +26 -0
- package/dist/config.d.ts +22 -1
- package/dist/config.js +156 -3
- package/dist/direct/directAudit.d.ts +23 -0
- package/dist/direct/directAudit.js +309 -0
- package/dist/direct/directGuards.d.ts +20 -0
- package/dist/direct/directGuards.js +137 -0
- package/dist/direct/directPatch.d.ts +17 -0
- package/dist/direct/directPatch.js +113 -0
- package/dist/direct/directSessionStore.d.ts +63 -0
- package/dist/direct/directSessionStore.js +192 -0
- package/dist/direct/directVerification.d.ts +12 -0
- package/dist/direct/directVerification.js +96 -0
- package/dist/doctor.js +73 -5
- package/dist/runner/agentInvocation.d.ts +23 -0
- package/dist/runner/agentInvocation.js +106 -0
- package/dist/runner/changeCapture.d.ts +29 -0
- package/dist/runner/changeCapture.js +107 -5
- package/dist/runner/runTask.js +70 -44
- package/dist/runner/simpleProcess.d.ts +21 -0
- package/dist/runner/simpleProcess.js +206 -0
- package/dist/runner/watch.js +17 -2
- package/dist/security/commandGuard.d.ts +2 -1
- package/dist/security/commandGuard.js +25 -3
- package/dist/security/riskEngine.d.ts +27 -0
- package/dist/security/riskEngine.js +118 -0
- package/dist/security/runtimeGuard.d.ts +6 -0
- package/dist/security/runtimeGuard.js +28 -0
- package/dist/smoke-test.js +1353 -7
- package/dist/tools/applyPatch.d.ts +16 -0
- package/dist/tools/applyPatch.js +41 -0
- package/dist/tools/auditSession.d.ts +5 -0
- package/dist/tools/auditSession.js +12 -0
- package/dist/tools/auditTask.d.ts +7 -0
- package/dist/tools/auditTask.js +94 -2
- package/dist/tools/createDirectSession.d.ts +14 -0
- package/dist/tools/createDirectSession.js +54 -0
- package/dist/tools/createTask.d.ts +48 -1
- package/dist/tools/createTask.js +298 -47
- package/dist/tools/finalizeDirectSession.d.ts +19 -0
- package/dist/tools/finalizeDirectSession.js +84 -0
- package/dist/tools/getTaskSummary.d.ts +47 -0
- package/dist/tools/getTaskSummary.js +64 -1
- package/dist/tools/healthCheck.d.ts +22 -12
- package/dist/tools/healthCheck.js +23 -6
- package/dist/tools/readWorkspaceFile.d.ts +7 -1
- package/dist/tools/readWorkspaceFile.js +48 -2
- package/dist/tools/registry.js +217 -19
- package/dist/tools/runVerification.d.ts +16 -0
- package/dist/tools/runVerification.js +32 -0
- package/dist/tools/savePlan.d.ts +1 -0
- package/dist/tools/savePlan.js +38 -7
- package/dist/tools/searchWorkspace.d.ts +19 -0
- package/dist/tools/searchWorkspace.js +205 -0
- package/dist/tools/taskTemplates.js +2 -1
- package/dist/tools/toolCatalog.d.ts +3 -2
- package/dist/tools/toolCatalog.js +32 -5
- package/dist/tools/waitForTask.d.ts +2 -2
- package/dist/tools/waitForTask.js +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/docs/chatgpt-usage.md +101 -0
- package/docs/release-checklist.md +14 -0
- package/docs/release-v0.6.0.md +71 -0
- package/examples/config.example.json +5 -0
- package/examples/openai-tunnel/README.md +11 -4
- package/examples/openai-tunnel/chatgpt-test-prompt.md +18 -14
- package/examples/openai-tunnel/tunnel-client.example.yaml +8 -0
- package/package.json +12 -8
- package/scripts/brand-check.js +58 -12
- package/scripts/control-smoke.js +206 -0
- package/scripts/launchers/Check-PatchWarden-Health.cmd +6 -0
- package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
- package/scripts/launchers/Restart-PatchWarden.cmd +6 -0
- package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +7 -0
- package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +7 -0
- package/scripts/lifecycle-smoke.js +66 -6
- package/scripts/manage-patchwarden.ps1 +639 -0
- package/scripts/mcp-manifest-check.js +100 -58
- package/scripts/mcp-smoke.js +157 -2
- package/scripts/pack-clean.js +3 -4
- package/scripts/package-manifest-check.js +78 -0
- package/scripts/patchwarden-mcp-direct.cmd +7 -0
- package/scripts/patchwarden-mcp-stdio.cmd +1 -1
- package/scripts/restart-patchwarden.ps1 +5 -4
- package/scripts/start-patchwarden-tunnel.ps1 +261 -30
- package/scripts/tunnel-supervisor-smoke.js +36 -4
- package/scripts/watcher-supervisor-smoke.js +10 -6
- package/src/assessments/agentAssessor.ts +324 -0
- package/src/assessments/assessmentStore.ts +426 -0
- package/src/assessments/confirmCli.ts +29 -0
- package/src/config.ts +178 -4
- package/src/direct/directAudit.ts +400 -0
- package/src/direct/directGuards.ts +279 -0
- package/src/direct/directPatch.ts +258 -0
- package/src/direct/directSessionStore.ts +345 -0
- package/src/direct/directVerification.ts +138 -0
- package/src/doctor.ts +103 -7
- package/src/runner/agentInvocation.ts +125 -0
- package/src/runner/changeCapture.ts +140 -5
- package/src/runner/runTask.ts +61 -43
- package/src/runner/simpleProcess.ts +223 -0
- package/src/runner/watch.ts +18 -2
- package/src/security/commandGuard.ts +46 -4
- package/src/security/riskEngine.ts +160 -0
- package/src/security/runtimeGuard.ts +41 -0
- package/src/smoke-test.ts +1291 -4
- package/src/tools/applyPatch.ts +86 -0
- package/src/tools/auditSession.ts +28 -0
- package/src/tools/auditTask.ts +100 -2
- package/src/tools/createDirectSession.ts +113 -0
- package/src/tools/createTask.ts +405 -55
- package/src/tools/finalizeDirectSession.ts +144 -0
- package/src/tools/getTaskSummary.ts +111 -1
- package/src/tools/healthCheck.ts +23 -6
- package/src/tools/readWorkspaceFile.ts +85 -2
- package/src/tools/registry.ts +242 -19
- package/src/tools/runVerification.ts +58 -0
- package/src/tools/savePlan.ts +57 -7
- package/src/tools/searchWorkspace.ts +275 -0
- package/src/tools/taskTemplates.ts +2 -1
- package/src/tools/toolCatalog.ts +35 -6
- package/src/tools/waitForTask.ts +3 -3
- package/src/version.ts +2 -2
- package/tsconfig.json +18 -17
- package/Check-PatchWarden-Health.cmd +0 -6
- package/Reset-PatchWarden-Tunnel-Key.cmd +0 -6
- package/Restart-PatchWarden.cmd +0 -19
- package/Start-PatchWarden-Tunnel.cmd +0 -7
- package/docs/release-v0.3.0.md +0 -43
- 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
|
-
|
|
22
|
-
$
|
|
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
|
-
$
|
|
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
|
-
$
|
|
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
|
-
|
|
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
|
-
$
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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
|
|
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]
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
99
|
-
schema_epoch: "2026-06-
|
|
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
|
|
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(
|
|
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
|
|