Repository navigation
Description
Client or integration
ocx CLI (Windows, Task Scheduler backend)
Area
Service lifecycle
Summary
windowsTaskRegistrationHealthy() compares the registered task XML against an XML-escaped <Arguments> string, but Windows Task Scheduler exports that element unescaped. The two can never be equal, so a healthy scheduler task is permanently reported as stale.
This produces the exact same user-facing symptom as the already-fixed #432, which makes it easy to mistake for a regression or duplicate — but the root cause is different and #432's fix does not cover it:
- fix(windows): startup safety falsely marks healthy scheduler service stale #432 — Windows omits default-valued elements (
<Enabled>,<RunLevel>) on export. Fixed by treating absence as the schema default (taskXmlOptionalValueEquals). - This issue — the element is present, but its text is canonicalized:
"becomes a literal". The equality check is a raw substring match, so it fails regardless of the fix(windows): startup safety falsely marks healthy scheduler service stale #432 fix.
Consequence: stale is latched true → viable and startable are both false → ocx stops treating the scheduler backend as usable and falls back to launching the proxy directly. Re-running ocx service install elevated cannot clear it, because a freshly registered task exports the same way.
Root cause (code level, v2.7.42)
src/service.ts:
-
taskXmlString()escapes"→":function taskXmlString(value: string): string { return value .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) // <-- here .replace(/'/g, "'"); }
-
The generated task XML therefore contains
<Arguments>/b /nologo "...vbs"</Arguments>. -
windowsTaskRegistrationHealthy()requires the registered XML to contain that same escaped form:&& action.includes(`<Arguments>${taskXmlString(`/b /nologo "${launcher}"`)}</Arguments>`);
-
But
"is a legal literal character inside XML element content, so Windows canonicalizes it on export.schtasks /query /xmlreturns:<Arguments>/b /nologo "C:\path\to\opencodex-service-launcher.vbs"</Arguments>
Since the registered XML never contains ", the includes() check is unconditionally false whenever the launcher path is quoted — which it always is.
Reproduction
Windows 11 Pro (26200), @bitkyc08/opencodex 2.7.42, Bun 1.3.14, Task Scheduler backend, task installed and running normally.
-
Confirm the proxy is actually healthy:
curl.exe -s http://127.0.0.1:10100/healthz # {"status":"ok","service":"opencodex","version":"2.7.42",...}
-
Confirm the scheduled task exists and is enabled:
Get-ScheduledTask -TaskName opencodex-proxy | Select-Object TaskName,State # opencodex-proxy Ready
-
Show that the registered XML contains no escaped quote:
$raw = & "$env:SystemRoot\System32\schtasks.exe" /query /tn opencodex-proxy /xml ONE | Out-String $raw.Contains('"') # False $raw -match '<Arguments>[^<]*\x22' # True (literal " present)
-
Observe the false positive:
ocx status # Service: installed, stale or missing service assets — run 'ocx service install' to repair
Note that bakedServicePathsDiagnostic() returns null here (both bunPath and cliPath exist, and the diagnostics string shows only logs: ... with no STALE baked paths prefix), and recordedBackend is scheduler, so staleBakedPaths and backendStateMismatch are both false — the stale flag can only be coming from schedulerInstalled && !schedulerAssetsHealthy, i.e. registrationHealthy === false.
Expected
A task whose registered <Arguments> is semantically identical to the generated one (differing only in XML escaping of characters that do not require escaping) is considered healthy.
Actual
registrationHealthy is false → stale is true → viable/startable false → scheduler backend treated as unusable; ocx update also attempts a service reinstall each time and reports the service as needing repair.
Suggested fix
Compare decoded text rather than raw substrings — unescape both sides (or at minimum normalize "/'/&) before the includes() check, in the same spirit as #432's "canonicalization-tolerant comparison". A regression test asserting that a task XML round-tripped through schtasks (literal ") still validates would prevent this class from recurring.
For context, the same canonicalization class has now bitten this function twice — once by element omission (#432), once by text escaping (this issue). It may be worth comparing against a parsed representation instead of doing string containment on raw XML.
Workaround
None that clears the flag. The proxy itself is unaffected and the logon trigger still works; only the health reporting and the scheduler-backend viability logic are wrong. Users can ignore the warning, but ocx will keep falling back to direct proxy launch instead of using the installed service.
Activity
github-actions commentedon Jul 28, 2026
Automated translation bookkeeping — detected language: English.
lidge-jun commentedon Jul 28, 2026
Triage: root cause confirmed, and it is distinct from #432
Reproduced the comparison mismatch by reading the current code on
dev(7710185c0). Your diagnosis holds.windowsTaskRegistrationHealthy()ends with a raw substring match atsrc/service.ts:1080:taskXmlString()(src/service.ts:886-893) escapes"to", so the needle is<Arguments>/b /nologo "C:\...\launcher.vbs"</Arguments>. Task Scheduler exports that element with the quotes canonicalized back to literal", soincludes()can never match — exactly as you describe.This is structurally different from #432, and its fix does not help here.
taskXmlOptionalValueEquals()(:1042) handles elements Task Scheduler omits on export by treating absence as the schema default. Here the element is present and its text is merely re-encoded, so it never reaches that helper —<Arguments>is checked by the rawincludes()above.Consequence chain matches your report:
registrationHealthy: falseat:1107latchesstale, which dropsviable/startable, soocxabandons the scheduler backend and launches the proxy directly. Re-runningocx service installelevated rewrites the same escaped XML and hits the same comparison, which is why reinstalling cannot clear it.The fix is local and does not need a design decision: unescape the exported
<Arguments>text ("&<>') before comparing, rather than escaping our side of the comparison. That is the same shape as the:1042helper — compare canonical values, not canonical encodings. A regression test should assert healthy=true for both the escaped and the canonicalized export, and healthy=false when the launcher path genuinely differs, so the fix cannot pass by loosening the check into a no-op.Noted as a fixable-without-owner-decision item in our triage. No timeline promised.
luvs01 commentedon Jul 28, 2026
Thanks for confirming the root cause. I prototyped and tested a narrowly scoped two-file patch locally against the current
devhead (ca7b1049); I have not opened a PR.Would a PR with the following shape be welcome?
<Arguments>substring check with a helper that requires one unique, unprefixed element, rejects nested markup, decodes XML's five predefined entities once, and then compares the exact decoded value.&launcher path), while still rejecting a genuinely foreign launcher even when the expected text appears in a comment, CDATA section, or nested element.Local checks on the current
devbase:bun test tests/service.test.ts: 42 passed, 0 failedbun x tsc --noEmit: passedIf a parsed-XML or shared-helper approach would fit the project better, I can adjust the proposed shape before any PR is opened.
fix(windows): compare scheduler action values, not their encodings (#608
lidge-jun commentedon Jul 29, 2026
Fixed on
devin 438b8dc.Your diagnosis held on every point, including that this is distinct from #432. The needle built by
taskXmlString()escaped"to", Task Scheduler exports that element with the quotes canonicalized back to literal", and the rawincludes()atsrc/service.ts:1080could never match.<Command>had the same defect.The fix takes the direction you and @luvs01 both proposed — compare decoded values rather than encoded ones, matching what
taskXmlOptionalValueEquals()already does:taskXmlDecodeEntities()decodes the five predefined entities in a single pass, so&quot;cannot impersonate a quote.taskXmlDecodedValueEquals()requires exactly one unprefixed element, refuses nested markup via[^<]*, and treats absence as a failure rather than a schema default — these elements name what actually executes.Since this loosens a check, the regressions target the negatives: a foreign launcher, a foreign interpreter, double-encoded quotes, and missing, duplicated, or namespace-prefixed
<Arguments>all still fail. Confirmed by mutation — reverting the fix fails 2 cases, degrading the comparison toreturn truefails 2, and decoding twice fails 1.@luvs01 thank you for the prototype and the shape proposal; the landed change follows it closely.
Verification:
bun test tests/service.test.ts43 pass / 0 fail,bun run test5970 pass / 0 fail, typecheck / privacy:scan green.fix(windows): compare scheduler action values, not their encodings (l…