@bitkyc08/opencodex 2.52.0-preview.20260912 → 2.53.0-preview.20260913

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 (236) hide show
  1. package/gui/dist/assets/index-BBOZWGB6.css +1 -0
  2. package/gui/dist/assets/index-D7ynYo2K.js +128 -0
  3. package/gui/dist/index.html +2 -2
  4. package/native/remote-workspace-helper/Cargo.lock +130 -0
  5. package/native/remote-workspace-helper/Cargo.toml +24 -0
  6. package/native/remote-workspace-helper/src/main.rs +49 -0
  7. package/native/remote-workspace-helper/src/protocol.rs +246 -0
  8. package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
  9. package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
  10. package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
  11. package/package.json +6 -1
  12. package/src/adapters/anthropic-image-normalize.ts +30 -2
  13. package/src/adapters/anthropic.ts +1 -1
  14. package/src/adapters/base.ts +8 -2
  15. package/src/adapters/cursor/cursor-errors.ts +12 -0
  16. package/src/adapters/cursor/thread-continuity.ts +93 -0
  17. package/src/adapters/cursor.ts +104 -73
  18. package/src/adapters/devin/cloud-direct/chat.ts +312 -23
  19. package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
  20. package/src/adapters/devin/live-models.ts +70 -3
  21. package/src/adapters/devin.ts +281 -21
  22. package/src/adapters/google-wire-compiler.ts +14 -6
  23. package/src/adapters/google.ts +22 -8
  24. package/src/adapters/kiro/adapter.ts +316 -0
  25. package/src/adapters/kiro/conversation.ts +136 -0
  26. package/src/adapters/kiro/payload.ts +432 -0
  27. package/src/adapters/kiro/reasoning.ts +56 -0
  28. package/src/adapters/kiro/stream.ts +1153 -0
  29. package/src/adapters/kiro/usage.ts +223 -0
  30. package/src/adapters/kiro/wire.ts +76 -0
  31. package/src/adapters/kiro.ts +8 -2319
  32. package/src/adapters/mimo-free.ts +1 -1
  33. package/src/adapters/openai-chat-images.ts +101 -0
  34. package/src/adapters/openai-chat.ts +201 -181
  35. package/src/adapters/openai-responses.ts +92 -224
  36. package/src/adapters/registry.ts +0 -7
  37. package/src/adapters/run-turn-queue.ts +13 -6
  38. package/src/bridge.ts +14 -15
  39. package/src/chat/inbound.ts +29 -4
  40. package/src/chat/outbound.ts +145 -107
  41. package/src/claude/desktop-profile.ts +4 -6
  42. package/src/cli/account-api.ts +14 -0
  43. package/src/cli/account-extended.ts +1 -1
  44. package/src/cli/account-history.ts +60 -0
  45. package/src/cli/account-main.ts +80 -0
  46. package/src/cli/account.ts +11 -3
  47. package/src/cli/capabilities.ts +113 -0
  48. package/src/cli/catalog.ts +109 -0
  49. package/src/cli/dispatch.ts +9 -0
  50. package/src/cli/help.ts +2 -0
  51. package/src/cli/index.ts +2 -2
  52. package/src/cli/observe.ts +28 -1
  53. package/src/cli/opencode.ts +42 -8
  54. package/src/cli/provider-runtime.ts +11 -1
  55. package/src/cli/provider.ts +22 -2
  56. package/src/cli/registry.ts +21 -0
  57. package/src/cli/remote-workspace.ts +154 -0
  58. package/src/cli/status.ts +39 -7
  59. package/src/cli/usage-report.ts +14 -2
  60. package/src/client/hub-client.ts +34 -0
  61. package/src/client/hub-state.ts +9 -1
  62. package/src/codex/account-store.ts +78 -0
  63. package/src/codex/auth-api.ts +81 -54
  64. package/src/codex/auth-context.ts +45 -16
  65. package/src/codex/catalog/effort.ts +1 -1
  66. package/src/codex/catalog/metadata.ts +3 -6
  67. package/src/codex/catalog/native-models.ts +4 -4
  68. package/src/codex/catalog/parsing.ts +2 -20
  69. package/src/codex/catalog/provider-fetch.ts +10 -1
  70. package/src/codex/catalog/remote.ts +233 -0
  71. package/src/codex/catalog/sync.ts +403 -35
  72. package/src/codex/convergence.ts +1 -1
  73. package/src/codex/history-manifest.ts +36 -0
  74. package/src/codex/history-provider.ts +32 -5
  75. package/src/codex/inject.ts +9 -0
  76. package/src/codex/main-account.ts +113 -0
  77. package/src/codex/main-device-reauth-api.ts +89 -0
  78. package/src/codex/main-device-reauth.ts +217 -0
  79. package/src/codex/native-residue.ts +9 -2
  80. package/src/codex/quota-auto-refresh.ts +3 -2
  81. package/src/codex/quota-capacity.ts +98 -0
  82. package/src/codex/quota-history.ts +160 -0
  83. package/src/codex/quota-types.ts +8 -0
  84. package/src/codex/quota.ts +118 -91
  85. package/src/codex/refresh.ts +2 -1
  86. package/src/codex/routing.ts +90 -17
  87. package/src/codex/sync.ts +33 -4
  88. package/src/combos/request.ts +19 -1
  89. package/src/config/multi-agent-surface.ts +61 -0
  90. package/src/config/provider-validation.ts +176 -0
  91. package/src/config.ts +213 -11
  92. package/src/generated/compatibility-version.json +436 -168
  93. package/src/images/loop.ts +119 -36
  94. package/src/lib/admission.ts +12 -6
  95. package/src/lib/redact.ts +7 -0
  96. package/src/lib/translator-budget.ts +4 -3
  97. package/src/lib/windows-atomic-replace.ts +1 -0
  98. package/src/lib/windows-elevation.ts +1 -1
  99. package/src/oauth/chatgpt-device.ts +62 -5
  100. package/src/oauth/devin/cli-import.ts +130 -0
  101. package/src/oauth/devin.ts +63 -8
  102. package/src/oauth/index.ts +29 -14
  103. package/src/oauth/kiro.ts +18 -6
  104. package/src/oauth/login-cli.ts +9 -1
  105. package/src/oauth/meta-muse-device.ts +464 -0
  106. package/src/oauth/meta-muse.ts +123 -32
  107. package/src/oauth/pool-kernel.ts +9 -0
  108. package/src/oauth/pool-settings-capability.ts +2 -2
  109. package/src/oauth/store.ts +57 -0
  110. package/src/oauth/types.ts +31 -0
  111. package/src/providers/derive.ts +13 -3
  112. package/src/providers/devin-cli-authmode-migration.ts +57 -35
  113. package/src/providers/devin-provider-merge-migration.ts +240 -0
  114. package/src/providers/muse-key-quota.ts +117 -0
  115. package/src/providers/muse-subscription-usage.ts +14 -2
  116. package/src/providers/openai-sidecar.ts +25 -3
  117. package/src/providers/opencode-zen-rate-limit.ts +58 -0
  118. package/src/providers/provider-id-rewrite.ts +20 -5
  119. package/src/providers/quota-types.ts +12 -0
  120. package/src/providers/quota.ts +143 -102
  121. package/src/providers/reasoning-metadata.ts +543 -0
  122. package/src/providers/registry.ts +80 -49
  123. package/src/reasoning-effort.ts +26 -2
  124. package/src/remote/hub-usage.ts +32 -0
  125. package/src/remote-control/index.ts +192 -41
  126. package/src/remote-control/workspace-activation.ts +9 -0
  127. package/src/remote-control/workspace-agent-connection.ts +366 -0
  128. package/src/remote-control/workspace-claude-runtime.ts +243 -0
  129. package/src/remote-control/workspace-codex-runtime.ts +531 -0
  130. package/src/remote-control/workspace-codex-sandbox.ts +115 -0
  131. package/src/remote-control/workspace-command-runner.ts +748 -0
  132. package/src/remote-control/workspace-coordinator.ts +231 -0
  133. package/src/remote-control/workspace-device.ts +585 -0
  134. package/src/remote-control/workspace-executable.ts +43 -0
  135. package/src/remote-control/workspace-executor.ts +397 -0
  136. package/src/remote-control/workspace-hub.ts +519 -0
  137. package/src/remote-control/workspace-pi-runtime.ts +382 -0
  138. package/src/remote-control/workspace-process.ts +129 -0
  139. package/src/remote-control/workspace-rpc.ts +304 -0
  140. package/src/remote-control/workspace-runtime.ts +60 -0
  141. package/src/remote-control/workspace-secret-store.ts +39 -0
  142. package/src/remote-control/workspace-sessions.ts +799 -0
  143. package/src/remote-control/workspace-tool-bridge.ts +192 -0
  144. package/src/responses/code-mode-helper-compat.ts +22 -3
  145. package/src/responses/hosted-tool-policy.ts +0 -1
  146. package/src/responses/muse-tool-name-alias.ts +379 -0
  147. package/src/responses/plaintext-v2-agent-messages.ts +902 -0
  148. package/src/router.ts +7 -0
  149. package/src/routing/compatibility/behavior.ts +0 -1
  150. package/src/server/audio-client.ts +64 -0
  151. package/src/server/audio-dictation.ts +91 -0
  152. package/src/server/audio-live.ts +185 -0
  153. package/src/server/audio-transcriptions.ts +183 -0
  154. package/src/server/audio-upstream.ts +153 -0
  155. package/src/server/auth-cors.ts +61 -2
  156. package/src/server/chat-completions.ts +1 -1
  157. package/src/server/chat-native-sse.ts +92 -48
  158. package/src/server/chat-native.ts +37 -15
  159. package/src/server/hub-usage.ts +57 -0
  160. package/src/server/images.ts +4 -0
  161. package/src/server/index.ts +722 -57
  162. package/src/server/lifecycle.ts +5 -6
  163. package/src/server/live-call-bindings.ts +60 -0
  164. package/src/server/live.ts +12 -1
  165. package/src/server/management/agent-settings-routes.ts +25 -4
  166. package/src/server/management/api-access.ts +37 -0
  167. package/src/server/management/api-key-usage.ts +7 -2
  168. package/src/server/management/config-routes.ts +1 -18
  169. package/src/server/management/context.ts +15 -0
  170. package/src/server/management/logs-usage-routes.ts +2 -0
  171. package/src/server/management/oauth-account-routes.ts +39 -12
  172. package/src/server/management/provider-routes.ts +125 -2
  173. package/src/server/management/remote-workspace-routes.ts +140 -0
  174. package/src/server/management/route-registry.ts +15 -0
  175. package/src/server/management/usage-aggregate-cache.ts +14 -15
  176. package/src/server/management/usage-summary-cache.ts +2 -0
  177. package/src/server/management-api.ts +23 -0
  178. package/src/server/ports.ts +17 -0
  179. package/src/server/relay-eager.ts +4 -1
  180. package/src/server/relay.ts +70 -10
  181. package/src/server/request-decompress.ts +6 -3
  182. package/src/server/responses/agent-task-recovery.ts +25 -32
  183. package/src/server/responses/codex-auth-error.ts +11 -0
  184. package/src/server/responses/codex-ws-exchange.ts +52 -3
  185. package/src/server/responses/codex-ws-wire.ts +55 -0
  186. package/src/server/responses/compact.ts +9 -1
  187. package/src/server/responses/core.ts +337 -73
  188. package/src/server/responses/encrypted-payload.ts +45 -2
  189. package/src/server/responses/ws-upstream.ts +4 -1
  190. package/src/server/responses-self-named-namespace-scrub.ts +1 -3
  191. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  192. package/src/server/search.ts +3 -0
  193. package/src/server/sse-payload-rewrite.ts +136 -51
  194. package/src/server/ws-bridge.ts +35 -1
  195. package/src/service/cli.ts +372 -0
  196. package/src/service/diagnostics.ts +340 -0
  197. package/src/service/guards.ts +303 -0
  198. package/src/service/health.ts +222 -0
  199. package/src/service/launchd.ts +853 -0
  200. package/src/service/orchestration.ts +617 -0
  201. package/src/service/repair.ts +334 -0
  202. package/src/service/state.ts +363 -0
  203. package/src/service/systemd.ts +229 -0
  204. package/src/service/windows-ops.ts +690 -0
  205. package/src/service/windows-scheduler.ts +769 -0
  206. package/src/service/windows-taskxml.ts +613 -0
  207. package/src/service.ts +22 -5550
  208. package/src/storage/cleanup/db.ts +258 -0
  209. package/src/storage/cleanup/execute.ts +358 -0
  210. package/src/storage/cleanup/paths.ts +189 -0
  211. package/src/storage/cleanup/pending.ts +140 -0
  212. package/src/storage/cleanup/preview.ts +292 -0
  213. package/src/storage/cleanup/reconcile.ts +347 -0
  214. package/src/storage/cleanup/restore.ts +932 -0
  215. package/src/storage/cleanup/satellite.ts +474 -0
  216. package/src/storage/cleanup/staging.ts +129 -0
  217. package/src/storage/cleanup/types.ts +98 -0
  218. package/src/storage/cleanup.ts +49 -3127
  219. package/src/types/accounts.ts +2 -0
  220. package/src/types/config.ts +13 -12
  221. package/src/types/provider.ts +37 -0
  222. package/src/types/request.ts +2 -0
  223. package/src/types/tools.ts +17 -5
  224. package/src/types.ts +1 -0
  225. package/src/usage/expected-prices.ts +127 -0
  226. package/src/usage/log.ts +58 -1
  227. package/src/vision/eligibility.ts +13 -2
  228. package/src/web-search/loop.ts +56 -3
  229. package/gui/dist/assets/index-D_t6sCWs.js +0 -115
  230. package/gui/dist/assets/index-EdoPnm9_.css +0 -1
  231. package/src/adapters/devin-cli/acp.ts +0 -204
  232. package/src/adapters/devin-cli/adapter.ts +0 -345
  233. package/src/adapters/devin-cli/binary.ts +0 -69
  234. package/src/adapters/devin-cli/models.ts +0 -57
  235. package/src/oauth/devin-cli.ts +0 -149
  236. package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
@@ -0,0 +1,613 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { TASK, windowsServiceScriptPath, windowsLauncherVbsPath, windowsTaskXmlPath } from "./state";
3
+ import { windowsWscript } from "./windows-scheduler";
4
+ import { join } from "node:path";
5
+ import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV } from "../lib/bun-runtime";
6
+ import { serviceApiTokenFilePath } from "../lib/service-secrets";
7
+ import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "../lib/win-paths";
8
+ import { cachedCurrentWindowsIdentity, resolveCurrentWindowsPrincipal, WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS } from "../lib/windows-user-principal";
9
+ import { resolveServiceListenPort, resolvedProxyEnv } from "./health";
10
+ import { cliEntry, serviceLogPath, currentCodexSqliteHomeAbsolute } from "./state";
11
+
12
+ function windowsBatchValue(value: string): string {
13
+ return value
14
+ .replace(/%/g, "%%")
15
+ .replace(/\^/g, "^^")
16
+ .replace(/"/g, "")
17
+ .replace(/[\r\n]/g, "");
18
+ }
19
+
20
+ type WindowsBatchValueKind = "raw" | "path" | "pathList";
21
+
22
+ function windowsBatchSet(name: string, value: string | undefined, kind: WindowsBatchValueKind = "raw"): string | null {
23
+ if (!value) return null;
24
+ const rendered =
25
+ kind === "path" ? windowsEnvIndirectBatchValue(value, windowsBatchValue)
26
+ : kind === "pathList" ? windowsEnvIndirectBatchPathList(value, windowsBatchValue)
27
+ : windowsBatchValue(value);
28
+ return `set "${name}=${rendered}"`;
29
+ }
30
+
31
+ function taskXmlString(value: string): string {
32
+ return value
33
+ .replace(/&/g, "&")
34
+ .replace(/</g, "&lt;")
35
+ .replace(/>/g, "&gt;")
36
+ .replace(/"/g, "&quot;")
37
+ .replace(/'/g, "&apos;");
38
+ }
39
+
40
+ /**
41
+ * RunLevel check. Schema default is LeastPrivilege (omitted on export). Elevated
42
+ * `schtasks /create` often rewrites the registered task to HighestAvailable even when
43
+ * the source XML asked for LeastPrivilege — still InteractiveToken / same user.
44
+ * Keep accepting HighestAvailable here: rejecting it would false-fail healthy elevated
45
+ * installs, and windowsTaskRegistrationHealthy tests encode that contract.
46
+ */
47
+ function taskXmlRunLevelAcceptable(principal: string): boolean {
48
+ if (taskXmlHasPrefixedTag(principal, "RunLevel")) return false;
49
+ const count = taskXmlElementCount(principal, "RunLevel");
50
+ if (count === 0) return true;
51
+ if (count > 1) return false;
52
+ const value = new RegExp(`<RunLevel(?:\\s[^>]*?)?>\\s*([^<]*?)\\s*<\\/RunLevel>`, "i").exec(principal)?.[1]?.trim().toLowerCase();
53
+ return value === "leastprivilege" || value === "highestavailable";
54
+ }
55
+
56
+ export function buildWindowsServiceScript(
57
+ entry = cliEntry(),
58
+ port = resolveServiceListenPort(),
59
+ proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(),
60
+ ): string {
61
+ // Provenance rides along with the entry: a second durableBunRuntime() call here could
62
+ // resolve differently from the binary the caller actually baked.
63
+ const { bun, bunRuntimeSource, cli } = entry;
64
+ const path = process.env.PATH ?? "";
65
+ const lines = [
66
+ "@echo off",
67
+ "setlocal",
68
+ // The wrapper console is hidden by the wscript launcher (window style 0), so switching
69
+ // it to UTF-8 is safe (no leak into user shells) and lets cmd parse UTF-8 remnants.
70
+ "chcp 65001 >nul",
71
+ windowsBatchSet("OCX_SERVICE", "1"),
72
+ windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
73
+ windowsBatchSet(BUN_RUNTIME_PATH_ENV, bun, "path"),
74
+ windowsBatchSet("PATH", path, "pathList"),
75
+ windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"),
76
+ windowsBatchSet("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute("windows"), "path"),
77
+ windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"),
78
+ ...proxyEnv.map(({ name, value }) => windowsBatchSet(name, value)),
79
+ windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath(), "path"),
80
+ windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath(), "path"),
81
+ windowsBatchSet("OCX_BUN", bun, "path"),
82
+ windowsBatchSet("OCX_CLI", cli, "path"),
83
+ // Package root for the transactional-update restore path (#1942): cli is
84
+ // <pkg>\src\cli\index.ts, so the package dir is three levels up.
85
+ 'for %%I in ("%OCX_CLI%\\..\\..\\..") do set "OCX_PKG_DIR=%%~fI"',
86
+ 'if exist "%OCX_API_TOKEN_FILE%" (',
87
+ ' set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"',
88
+ ")",
89
+ ":loop",
90
+ '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] opencodex service wrapper start',
91
+ '>>"%OCX_SERVICE_LOG%" echo bun="%OCX_BUN%"',
92
+ `>>"%OCX_SERVICE_LOG%" echo bun_source="${bunRuntimeSource}"`,
93
+ '>>"%OCX_SERVICE_LOG%" echo cli="%OCX_CLI%"',
94
+ '>>"%OCX_SERVICE_LOG%" echo opencodex_home="%OPENCODEX_HOME%"',
95
+ '>>"%OCX_SERVICE_LOG%" echo codex_home="%CODEX_HOME%"',
96
+ '>>"%OCX_SERVICE_LOG%" echo token_file="%OCX_API_TOKEN_FILE%"',
97
+ 'if not exist "%OCX_BUN%" (',
98
+ " call :restore_backup",
99
+ ")",
100
+ 'if not exist "%OCX_BUN%" (',
101
+ ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] installation is incomplete: bundled Bun is missing; reinstall opencodex, then run ocx service repair',
102
+ " exit /b 3",
103
+ ")",
104
+ 'if not exist "%OCX_CLI%" (',
105
+ " call :restore_backup",
106
+ ")",
107
+ 'if not exist "%OCX_CLI%" (',
108
+ ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] installation is incomplete: CLI entry is missing; reinstall opencodex, then run ocx service repair',
109
+ " exit /b 3",
110
+ ")",
111
+ `"%OCX_BUN%" "%OCX_CLI%" start --port ${port} >>"%OCX_SERVICE_LOG%" 2>&1`,
112
+ "if %ERRORLEVEL% NEQ 0 (",
113
+ ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] child exited with code %ERRORLEVEL%; restarting in 5s',
114
+ // `timeout` needs console stdin and dies with "Input redirection is not supported"
115
+ // under Task Scheduler, turning the 5s cooldown into a hot restart loop; ping doesn't.
116
+ " ping -n 6 127.0.0.1 >nul",
117
+ " goto loop",
118
+ ")",
119
+ "endlocal",
120
+ "goto :eof",
121
+ "",
122
+ // #1942/#1849: a power loss mid-swap leaves the live package dir missing/broken and
123
+ // a sibling .ocx-backup-* holding the previous version. This wrapper lives OUTSIDE
124
+ // the package tree, so it can restore when the launcher itself is gone — the exact
125
+ // window the in-launcher boot probe cannot reach.
126
+ ":restore_backup",
127
+ '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] install incomplete - looking for a transactional-update backup to restore',
128
+ 'for /f "delims=" %%B in (\'dir /b /ad /o-n "%OCX_PKG_DIR%\\..\\.ocx-backup-*" 2^>nul\') do (',
129
+ ' if exist "%OCX_PKG_DIR%\\..\\%%B\\opencodex\\package.json" (',
130
+ ' if exist "%OCX_PKG_DIR%" rmdir /s /q "%OCX_PKG_DIR%" 2>nul',
131
+ ' move "%OCX_PKG_DIR%\\..\\%%B\\opencodex" "%OCX_PKG_DIR%" >nul 2>&1',
132
+ ' if exist "%OCX_PKG_DIR%\\package.json" (',
133
+ ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] restored previous install from %%B',
134
+ " goto :eof",
135
+ " )",
136
+ " )",
137
+ ")",
138
+ '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] no restorable backup found',
139
+ "goto :eof",
140
+ ].filter((line): line is string => Boolean(line));
141
+ return `${lines.join("\r\n")}\r\n`;
142
+ }
143
+
144
+ export function buildWindowsSchtasksCreateArgs(script = windowsServiceScriptPath()): string[] {
145
+ const xml = script === windowsServiceScriptPath() ? windowsTaskXmlPath() : `${script}.xml`;
146
+ return ["/create", "/tn", TASK, "/xml", xml, "/f"];
147
+ }
148
+
149
+ /** Build the fixed scheduler-create command from an explicit staged XML document. */
150
+ export function buildWindowsSchtasksCreateArgsForXml(xml: string, replace = true): string[] {
151
+ return ["/create", "/tn", TASK, "/xml", xml, ...(replace ? ["/f"] : [])];
152
+ }
153
+
154
+ /**
155
+ * VBS launcher that starts the batch wrapper with a hidden window (style 0).
156
+ * bWaitOnReturn=True keeps wscript.exe resident for the wrapper's lifetime so the
157
+ * scheduled task stays "running": MultipleInstancesPolicy=IgnoreNew keeps preventing
158
+ * duplicates and `schtasks /end` still has a live task instance to stop. Without the
159
+ * launcher, the console batch action shows a closable cmd window in the interactive
160
+ * session (issue #165). VBS string literals escape `"` as `""`.
161
+ */
162
+ export function buildWindowsLauncherVbs(script = windowsServiceScriptPath()): string {
163
+ const escaped = script.replace(/"/g, '""');
164
+ const lines = [
165
+ "' OpenCodex service launcher — runs the batch wrapper with a hidden window.",
166
+ "' Generated by `ocx service install`; do not edit.",
167
+ 'Set shell = CreateObject("WScript.Shell")',
168
+ // WshShell.Run(command, windowStyle 0 = hidden, bWaitOnReturn True = stay resident).
169
+ `shell.Run """${escaped}""", 0, True`,
170
+ ];
171
+ return `${lines.join("\r\n")}\r\n`;
172
+ }
173
+
174
+ function windowsTaskDescription(attemptNonce?: string): string {
175
+ return attemptNonce
176
+ ? `OpenCodex proxy service wrapper; install-attempt=${attemptNonce}`
177
+ : "OpenCodex proxy service wrapper";
178
+ }
179
+
180
+ /**
181
+ * Session transitions that must be able to bring the proxy back.
182
+ *
183
+ * The task runs under `InteractiveToken`, so the proxy lives inside the interactive session
184
+ * and Windows tears it down with that session — the wrapper records the kill as exit code
185
+ * 1073807364 (`STATUS_CONTROL_C_EXIT`). With `LogonTrigger` as the only trigger there was no
186
+ * recovery path short of a fresh logon, so signing out of a Remote Desktop session left the
187
+ * proxy down until the next interactive logon. On one machine's logs 19 such kills produced
188
+ * gaps of up to ~60 hours.
189
+ *
190
+ * These triggers do not stop the kill; they make it recoverable at the next connect. Console
191
+ * transitions are included because a local session can be disconnected the same way, and
192
+ * `MultipleInstancesPolicy=IgnoreNew` keeps a still-running proxy from being started twice.
193
+ */
194
+ const WINDOWS_SESSION_RECOVERY_STATE_CHANGES = [
195
+ "RemoteConnect",
196
+ "SessionUnlock",
197
+ "ConsoleConnect",
198
+ ] as const;
199
+
200
+ export function buildWindowsTaskXml(
201
+ script = windowsServiceScriptPath(),
202
+ launcher = windowsLauncherVbsPath(),
203
+ attemptNonce?: string,
204
+ sessionTriggerUserId = cachedCurrentWindowsIdentity()?.sid,
205
+ ): string {
206
+ const escapedWscript = taskXmlString(windowsWscript());
207
+ // Escape the launcher path independently for the <Arguments> element; quoting it
208
+ // keeps spaces intact, and /b (batch mode) suppresses script error popups.
209
+ const escapedLauncherArgs = taskXmlString(`/b /nologo "${launcher}"`);
210
+ // `UserId` is optional in the schema, and omitting it makes a SessionStateChangeTrigger
211
+ // fire for ANY account's session change. An unscoped LogonTrigger is read as "any user's
212
+ // logon", which a non-elevated user may not register: schtasks /create answers "Access is
213
+ // denied" for a task whose design (InteractiveToken + LeastPrivilege) needs no elevation
214
+ // (#4425). Production registration resolves and passes the installing account SID
215
+ // explicitly; the optional parameter remains only for deterministic builders/tests, and
216
+ // the live validator rejects an unscoped recovery trigger.
217
+ const sessionUserIdElement = sessionTriggerUserId
218
+ ? `\n <UserId>${taskXmlString(sessionTriggerUserId)}</UserId>`
219
+ : "";
220
+ return `<?xml version="1.0" encoding="UTF-16"?>
221
+ <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
222
+ <RegistrationInfo>
223
+ <Description>${taskXmlString(windowsTaskDescription(attemptNonce))}</Description>
224
+ </RegistrationInfo>
225
+ <Triggers>
226
+ <LogonTrigger>
227
+ <Enabled>true</Enabled>${sessionUserIdElement}
228
+ </LogonTrigger>
229
+ ${WINDOWS_SESSION_RECOVERY_STATE_CHANGES.map(stateChange => `<SessionStateChangeTrigger>
230
+ <Enabled>true</Enabled>${sessionUserIdElement}
231
+ <StateChange>${stateChange}</StateChange>
232
+ </SessionStateChangeTrigger>`).join("\n ")}
233
+ </Triggers>
234
+ <Principals>
235
+ <Principal id="Author">
236
+ <LogonType>InteractiveToken</LogonType>
237
+ <RunLevel>LeastPrivilege</RunLevel>
238
+ </Principal>
239
+ </Principals>
240
+ <Settings>
241
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
242
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
243
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
244
+ <AllowHardTerminate>true</AllowHardTerminate>
245
+ <StartWhenAvailable>true</StartWhenAvailable>
246
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
247
+ <AllowStartOnDemand>true</AllowStartOnDemand>
248
+ <Enabled>true</Enabled>
249
+ <Hidden>false</Hidden>
250
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
251
+ <Priority>4</Priority>
252
+ <RestartOnFailure>
253
+ <Interval>PT1M</Interval>
254
+ <Count>3</Count>
255
+ </RestartOnFailure>
256
+ </Settings>
257
+ <Actions Context="Author">
258
+ <Exec>
259
+ <Command>${escapedWscript}</Command>
260
+ <Arguments>${escapedLauncherArgs}</Arguments>
261
+ </Exec>
262
+ </Actions>
263
+ </Task>
264
+ `;
265
+ }
266
+
267
+ export type ExpectedWindowsTaskUserId = string | readonly string[];
268
+
269
+ export function cachedWindowsTaskUserIds(): readonly string[] | null {
270
+ const identity = cachedCurrentWindowsIdentity();
271
+ return identity ? [identity.sid, identity.name] : null;
272
+ }
273
+
274
+ export function resolvedWindowsTaskSid(): string {
275
+ let identity = cachedCurrentWindowsIdentity();
276
+ if (!identity) {
277
+ const principal = resolveCurrentWindowsPrincipal(WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS);
278
+ identity = cachedCurrentWindowsIdentity();
279
+ if (!identity && /^\*S-1-(?:\d+-)+\d+$/i.test(principal)) return principal.slice(1).toUpperCase();
280
+ }
281
+ if (!identity) throw new Error("Windows Task Scheduler identity could not be resolved.");
282
+ return identity.sid;
283
+ }
284
+
285
+ /** Render the exact UTF-16 task document published by production registration paths. */
286
+ export function buildWindowsTaskXmlDocument(
287
+ script = windowsServiceScriptPath(),
288
+ launcher = windowsLauncherVbsPath(),
289
+ attemptNonce?: string,
290
+ sessionTriggerUserId = resolvedWindowsTaskSid(),
291
+ ): string {
292
+ return `\uFEFF${buildWindowsTaskXml(script, launcher, attemptNonce, sessionTriggerUserId)}`;
293
+ }
294
+
295
+ export function taskXmlSection(xml: string, tag: string): string {
296
+ return new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, "i").exec(xml)?.[1] ?? "";
297
+ }
298
+
299
+ /** Drop comments and CDATA so a commented-out decoy cannot satisfy any check. */
300
+ export function taskXmlWithoutCommentsAndCdata(xml: string): string {
301
+ return xml.replace(/<!--[\s\S]*?-->/g, "").replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, "");
302
+ }
303
+
304
+ /**
305
+ * Count occurrences of an unprefixed tag, including the self-closing form. The
306
+ * element boundary matters: `<EnabledExtra>` must not count as `Enabled`.
307
+ */
308
+ export function taskXmlElementCount(xml: string, tag: string): number {
309
+ return xml.match(new RegExp(`<${tag}(?:\\s[^>]*?)?\\s*\\/?>`, "gi"))?.length ?? 0;
310
+ }
311
+
312
+ /**
313
+ * True when a namespace-prefixed form of the tag appears. A prefixed element bound
314
+ * to the task namespace carries a real value, but this module parses by regex and
315
+ * cannot resolve prefixes — so it fails closed instead of reading the element as
316
+ * absent (which would silently apply the schema default).
317
+ */
318
+ function taskXmlHasPrefixedTag(xml: string, tag: string): boolean {
319
+ return new RegExp(`<[A-Za-z_][\\w.-]*:${tag}(?:[\\s/>])`, "i").test(xml);
320
+ }
321
+
322
+ /**
323
+ * Compare an element that Task Scheduler may omit when exporting a registered task.
324
+ * Absence means the documented schema default (#432); a present element must still
325
+ * match exactly, so a malformed or explicitly unsafe value never reads as healthy.
326
+ */
327
+ /**
328
+ * Decode XML's five predefined entities, exactly once.
329
+ *
330
+ * Task Scheduler re-encodes element text when it exports a task, so a needle we
331
+ * escaped ourselves can never match its output (#608). Compare decoded values
332
+ * instead of encoded ones.
333
+ *
334
+ * The single pass is the point: decoding twice would turn `&amp;quot;` into `"`,
335
+ * letting a doubly-encoded value impersonate the expected launcher path.
336
+ */
337
+ function taskXmlDecodeEntities(value: string): string {
338
+ return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, name: string) => (
339
+ name === "amp" ? "&"
340
+ : name === "lt" ? "<"
341
+ : name === "gt" ? ">"
342
+ : name === "quot" ? "\""
343
+ : "'"
344
+ ));
345
+ }
346
+
347
+ /**
348
+ * Exactly one unprefixed `<tag>` whose DECODED text equals `expected`.
349
+ *
350
+ * Unlike taskXmlOptionalValueEquals(), an absent element is NOT a pass: these
351
+ * elements name what actually gets executed, so a missing <Command>/<Arguments>
352
+ * must fail the health check rather than inherit a schema default.
353
+ */
354
+ function taskXmlDecodedValueEquals(xml: string, tag: string, expected: string): boolean {
355
+ // Same reasoning as the optional helper: `<t:Arguments>` must not read as absent.
356
+ if (taskXmlHasPrefixedTag(xml, tag)) return false;
357
+ if (taskXmlElementCount(xml, tag) !== 1) return false;
358
+ // `[^<]*` refuses nested markup, so a decoy inside a child element cannot match.
359
+ const value = new RegExp(`<${tag}(?:\\s[^>]*?)?>([^<]*)<\\/${tag}>`, "i").exec(xml)?.[1];
360
+ if (value === undefined) return false;
361
+ return taskXmlDecodeEntities(value).trim().toLowerCase() === expected.trim().toLowerCase();
362
+ }
363
+
364
+ /**
365
+ * Characters a console code page substitutes when it cannot carry the original.
366
+ * Windows writes `?` per unrepresentable character, some layers write U+FFFD, and a
367
+ * few drop them entirely.
368
+ */
369
+ const CODE_PAGE_SUBSTITUTIONS = /^[?\uFFFD]*$/;
370
+
371
+ /**
372
+ * Compare a value that OpenCodex itself wrote against what `schtasks /query /xml` read
373
+ * back, tolerating ONLY the characters the console code page could not carry.
374
+ *
375
+ * `runFile` already reads the query as bytes, so this is not a spawn-decoding bug: the
376
+ * conversion happens inside `schtasks` before the bytes exist. A profile named outside
377
+ * the active code page — `C:\\Users\\김병준\\...` — comes back as `C:\\Users\\???\\...`, so an
378
+ * exact comparison rejected a registration this process had just created correctly and
379
+ * `ocx service install` rolled it back (#3064).
380
+ *
381
+ * The tolerance is deliberately narrow. Each unrepresentable RUN in the expected value
382
+ * may match only a run of substitution characters — never arbitrary text, and never a
383
+ * path separator. A wildcard as wide as `[^\\\\/]*` would leave a fully non-ASCII segment with
384
+ * no anchors at all, so `C:\\Users\\김병준\\x.vbs` would match `C:\\Users\\Admin\\x.vbs` and this
385
+ * process would adopt, repair, or delete another account's task. Accepting a foreign
386
+ * live task is a worse failure than the rollback this fixes.
387
+ */
388
+ function taskXmlLossyValueEquals(reported: string, expected: string): boolean {
389
+ const a = reported.trim().toLowerCase();
390
+ const b = expected.trim().toLowerCase();
391
+ if (a === b) return true;
392
+ // Nothing unrepresentable in the expectation means there was nothing to mangle,
393
+ // so any difference is a real one.
394
+ if (!/[^\x00-\x7F]/.test(b)) return false;
395
+ const parts = b.split(/([^\x00-\x7F]+)/);
396
+ let rest = a;
397
+ for (let i = 0; i < parts.length; i += 1) {
398
+ const part = parts[i]!;
399
+ if (i % 2 === 0) {
400
+ // Literal ASCII run: it must be present verbatim, which is what keeps every
401
+ // directory boundary and file name in the path verified.
402
+ if (!rest.startsWith(part)) return false;
403
+ rest = rest.slice(part.length);
404
+ continue;
405
+ }
406
+ // Unrepresentable run: consume only substitution characters, and stop at the
407
+ // next literal so a trailing run cannot swallow the remainder of the string.
408
+ const next = parts[i + 1] ?? "";
409
+ const end = next === "" ? rest.length : rest.indexOf(next);
410
+ if (end < 0) return false;
411
+ if (!CODE_PAGE_SUBSTITUTIONS.test(rest.slice(0, end))) return false;
412
+ rest = rest.slice(end);
413
+ }
414
+ return rest === "";
415
+ }
416
+
417
+ function taskXmlDecodedLossyValueEquals(xml: string, tag: string, expected: string): boolean {
418
+ if (taskXmlHasPrefixedTag(xml, tag)) return false;
419
+ if (taskXmlElementCount(xml, tag) !== 1) return false;
420
+ const value = new RegExp(`<${tag}(?:\\s[^>]*?)?>([^<]*)<\\/${tag}>`, "i").exec(xml)?.[1];
421
+ if (value === undefined) return false;
422
+ return taskXmlLossyValueEquals(taskXmlDecodeEntities(value), expected);
423
+ }
424
+
425
+ export function taskXmlOptionalValueEquals(xml: string, tag: string, expected: string): boolean {
426
+ // Check the prefixed form first: treating `<t:Enabled>false</t:Enabled>` as an
427
+ // omission would turn an explicitly disabled task into a healthy one.
428
+ if (taskXmlHasPrefixedTag(xml, tag)) return false;
429
+ const count = taskXmlElementCount(xml, tag);
430
+ if (count === 0) return true;
431
+ if (count > 1) return false;
432
+ const value = new RegExp(`<${tag}(?:\\s[^>]*?)?>\\s*([^<]*?)\\s*<\\/${tag}>`, "i").exec(xml)?.[1];
433
+ return value?.trim().toLowerCase() === expected.toLowerCase();
434
+ }
435
+
436
+ /** True only when the exported live task carries this install attempt's nonce. */
437
+ export function windowsTaskRegistrationOwnedByAttempt(xml: string, attemptNonce: string): boolean {
438
+ if (!attemptNonce) return false;
439
+ const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
440
+ if (taskXmlElementCount(scrubbed, "Data") > 0 || taskXmlHasPrefixedTag(scrubbed, "Data")) return false;
441
+ if (taskXmlHasPrefixedTag(scrubbed, "RegistrationInfo")) return false;
442
+ if (taskXmlElementCount(scrubbed, "RegistrationInfo") !== 1) return false;
443
+ const registrationInfo = taskXmlSection(scrubbed, "RegistrationInfo");
444
+ return taskXmlDecodedValueEquals(
445
+ registrationInfo,
446
+ "Description",
447
+ windowsTaskDescription(attemptNonce),
448
+ );
449
+ }
450
+
451
+ /**
452
+ * Every session-recovery trigger present and enabled, scoped to <Triggers>.
453
+ *
454
+ * Each StateChange is matched inside its OWN <SessionStateChangeTrigger> element: a document
455
+ * carrying one disabled trigger plus a different enabled one must not pass because the two
456
+ * halves were found in unrelated elements.
457
+ */
458
+ export function windowsTaskHasSessionRecoveryTriggers(
459
+ triggers: string,
460
+ expectedUserId: ExpectedWindowsTaskUserId | undefined,
461
+ ): boolean {
462
+ const scoped = triggers.match(/<SessionStateChangeTrigger(?:\s[^>]*)?>[\s\S]*?<\/SessionStateChangeTrigger>/gi) ?? [];
463
+ return WINDOWS_SESSION_RECOVERY_STATE_CHANGES.every(stateChange =>
464
+ scoped.some(element =>
465
+ taskXmlDecodedValueEquals(element, "StateChange", stateChange)
466
+ && taskXmlOptionalValueEquals(element, "Enabled", "true")
467
+ && windowsTaskTriggerScopeAcceptable(element, expectedUserId)));
468
+ }
469
+
470
+ /**
471
+ * A trigger's scope is acceptable only when it names the expected account exactly.
472
+ *
473
+ * An unscoped recovery trigger is not identity proof. Production registration resolves a SID
474
+ * before writing XML; a missing scope therefore means the fixed-name task is legacy or foreign
475
+ * and must be refreshed from an exact legacy snapshot or preserved for manual review.
476
+ * Treating an unknown expected identity as a wildcard would let a fresh status process accept a
477
+ * task bound to another user's session and suppress the repair that should replace it.
478
+ */
479
+ function windowsTaskTriggerScopeAcceptable(
480
+ element: string,
481
+ expectedUserId: ExpectedWindowsTaskUserId | undefined,
482
+ ): boolean {
483
+ // A prefixed `<t:UserId>` is a real scope this validator cannot read: taskXmlElementCount()
484
+ // counts only unprefixed tags, so without this the element below would look ABSENT and the
485
+ // trigger would be accepted as unscoped even though it is bound to some other account.
486
+ // Reject it outright rather than guess, and do so before the optional-field check.
487
+ if (taskXmlHasPrefixedTag(element, "UserId")) return false;
488
+ const userIdCount = taskXmlElementCount(element, "UserId");
489
+ if (userIdCount === 0) return false;
490
+ if (userIdCount !== 1) return false;
491
+ if (expectedUserId === undefined) return false;
492
+ // Scope is an identity boundary, unlike the launcher path. Newly generated tasks
493
+ // use the locale-independent SID from cachedCurrentWindowsIdentity(), so there is
494
+ // no reason to forgive code-page substitutions here. A lossy account-name compare
495
+ // lets two non-ASCII users collapse to the same `???` value and can make repair
496
+ // start another account's fixed-name task.
497
+ const expectedValues = typeof expectedUserId === "string" ? [expectedUserId] : expectedUserId;
498
+ return expectedValues.some(value => taskXmlDecodedValueEquals(element, "UserId", value));
499
+ }
500
+
501
+ /** Validate the stable OpenCodex action, principal, settings, and logon trigger. */
502
+ function windowsTaskRegistrationBaseHealthy(
503
+ xml: string,
504
+ wscript = windowsWscript(),
505
+ launcher = windowsLauncherVbsPath(),
506
+ allowLossyPaths = true,
507
+ ): boolean {
508
+ const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
509
+ // taskXmlSection() takes the FIRST match and the schema allows arbitrary XML under
510
+ // Task/Data, so a Data block placed before the real sections could shadow them.
511
+ // We never emit Data, so its presence alone disqualifies the registration. Both
512
+ // forms are rejected because taskXmlElementCount() ignores prefixed tags.
513
+ if (taskXmlElementCount(scrubbed, "Data") > 0 || taskXmlHasPrefixedTag(scrubbed, "Data")) return false;
514
+ const triggers = taskXmlSection(scrubbed, "Triggers");
515
+ const trigger = taskXmlSection(triggers, "LogonTrigger");
516
+ const principal = taskXmlSection(scrubbed, "Principal");
517
+ const settings = taskXmlSection(scrubbed, "Settings");
518
+ const action = taskXmlSection(scrubbed, "Exec");
519
+ // A self-closing <LogonTrigger /> leaves an empty section, so look for the element
520
+ // itself — scoped to <Triggers> so a decoy elsewhere cannot satisfy it.
521
+ return taskXmlElementCount(triggers, "LogonTrigger") > 0
522
+ && taskXmlOptionalValueEquals(trigger, "Enabled", "true")
523
+ && /<LogonType>\s*InteractiveToken\s*<\/LogonType>/i.test(principal)
524
+ && taskXmlRunLevelAcceptable(principal)
525
+ && taskXmlOptionalValueEquals(settings, "Enabled", "true")
526
+ && /<MultipleInstancesPolicy>\s*IgnoreNew\s*<\/MultipleInstancesPolicy>/i.test(settings)
527
+ && /<ExecutionTimeLimit>\s*PT0S\s*<\/ExecutionTimeLimit>/i.test(settings)
528
+ // Compare decoded VALUES, not encodings: Task Scheduler canonicalizes the
529
+ // quotes we wrote as `&quot;` back to literal `"` on export, so an escaped
530
+ // needle never matched and a healthy task read as permanently stale (#608).
531
+ // Case-insensitive: elevated `schtasks /create` may rewrite System32 casing.
532
+ // Lossy on purpose: both name paths under the user profile, which the query
533
+ // cannot carry when the profile is named outside the code page (#3064). Only
534
+ // unrepresentable characters are forgiven; every ASCII segment and every
535
+ // separator is still matched literally.
536
+ && (allowLossyPaths
537
+ ? taskXmlDecodedLossyValueEquals(action, "Command", wscript)
538
+ && taskXmlDecodedLossyValueEquals(action, "Arguments", `/b /nologo "${launcher}"`)
539
+ : taskXmlDecodedValueEquals(action, "Command", wscript)
540
+ && taskXmlDecodedValueEquals(action, "Arguments", `/b /nologo "${launcher}"`));
541
+ }
542
+
543
+ /** Validate the security/lifecycle-critical fields of the registered scheduler task. */
544
+ export function windowsTaskRegistrationHealthy(
545
+ xml: string,
546
+ wscript = windowsWscript(),
547
+ launcher = windowsLauncherVbsPath(),
548
+ expectedUserId: ExpectedWindowsTaskUserId | null = cachedWindowsTaskUserIds(),
549
+ ): boolean {
550
+ const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
551
+ const triggers = taskXmlSection(scrubbed, "Triggers");
552
+ return windowsTaskRegistrationBaseHealthy(xml, wscript, launcher)
553
+ // Without these the task can only recover at the next logon, so a disconnected session
554
+ // leaves the proxy down indefinitely. Treating their absence as unhealthy is what lets
555
+ // an already-registered task from an older install get repaired instead of staying broken.
556
+ && windowsTaskHasSessionRecoveryTriggers(triggers, expectedUserId ?? undefined);
557
+ }
558
+
559
+ /**
560
+ * The only stale definition repair may replace automatically: the previous OpenCodex task
561
+ * shape whose action/principal/settings are byte-exact and which has no session triggers yet.
562
+ * Arbitrary unhealthy or partially modified fixed-name tasks are preserved for manual review.
563
+ */
564
+ export function windowsTaskRegistrationRefreshableLegacy(
565
+ xml: string,
566
+ wscript = windowsWscript(),
567
+ launcher = windowsLauncherVbsPath(),
568
+ ): boolean {
569
+ const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
570
+ const triggers = taskXmlSection(scrubbed, "Triggers");
571
+ return windowsTaskRegistrationBaseHealthy(xml, wscript, launcher, false)
572
+ && taskXmlElementCount(triggers, "SessionStateChangeTrigger") === 0
573
+ && !taskXmlHasPrefixedTag(triggers, "SessionStateChangeTrigger");
574
+ }
575
+
576
+ export interface WindowsSchedulerXmlState {
577
+ installed: boolean;
578
+ enabled: boolean;
579
+ registrationHealthy: boolean;
580
+ }
581
+
582
+ /**
583
+ * Single source of truth for reading a registered task's XML. Both the status
584
+ * diagnostic and its tests go through here, so a partial fix cannot leave one
585
+ * caller on an older, stricter reading of the same document (#432).
586
+ */
587
+ export function readWindowsSchedulerXmlState(
588
+ xml: string,
589
+ wscript?: string,
590
+ launcher?: string,
591
+ expectedUserId: ExpectedWindowsTaskUserId | null = cachedWindowsTaskUserIds(),
592
+ ): WindowsSchedulerXmlState {
593
+ const installed = xml.length > 0;
594
+ if (!installed) return { installed: false, enabled: false, registrationHealthy: false };
595
+ const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
596
+ const hasData = taskXmlElementCount(scrubbed, "Data") > 0 || taskXmlHasPrefixedTag(scrubbed, "Data");
597
+ const settings = hasData ? "" : taskXmlSection(scrubbed, "Settings");
598
+ return {
599
+ installed: true,
600
+ enabled: !hasData && taskXmlOptionalValueEquals(settings, "Enabled", "true"),
601
+ registrationHealthy: windowsTaskRegistrationHealthy(xml, wscript, launcher, expectedUserId),
602
+ };
603
+ }
604
+
605
+ // ── macOS (launchd) ──
606
+ /** Read a file as UTF-8, or null when it is absent/unreadable. */
607
+ export function readTextOrNull(path: string): string | null {
608
+ try {
609
+ return readFileSync(path, "utf8");
610
+ } catch {
611
+ return null;
612
+ }
613
+ }