patchwarden 0.4.0 → 0.6.1

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