Skip to content

[Bug] Windows: healthy scheduler task permanently reported stale — <Arguments> compared escaped vs. canonicalized (distinct root cause from #432) #608

Closed

Description

@NashCC1550

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:

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:

  1. taskXmlString() escapes " → &quot;:

    function taskXmlString(value: string): string {
      return value
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")     // <-- here
        .replace(/'/g, "&apos;");
    }
  2. The generated task XML therefore contains <Arguments>/b /nologo &quot;...vbs&quot;</Arguments>.

  3. windowsTaskRegistrationHealthy() requires the registered XML to contain that same escaped form:

    && action.includes(`<Arguments>${taskXmlString(`/b /nologo "${launcher}"`)}</Arguments>`);
  4. But " is a legal literal character inside XML element content, so Windows canonicalizes it on export. schtasks /query /xml returns:

    <Arguments>/b /nologo "C:\path\to\opencodex-service-launcher.vbs"</Arguments>

Since the registered XML never contains &quot;, 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.

  1. Confirm the proxy is actually healthy:

    curl.exe -s http://127.0.0.1:10100/healthz
    # {"status":"ok","service":"opencodex","version":"2.7.42",...}
  2. Confirm the scheduled task exists and is enabled:

    Get-ScheduledTask -TaskName opencodex-proxy | Select-Object TaskName,State
    # opencodex-proxy   Ready
  3. 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('&quot;')            # False
    $raw -match '<Arguments>[^<]*\x22' # True  (literal " present)
  4. 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 &quot;/&apos;/&amp;) 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

  1. github-actions commented on Jul 28, 2026

    @github-actions
    Contributor

    Automated translation bookkeeping — detected language: English.

  2. lidge-jun commented on Jul 28, 2026

    @lidge-jun
    Owner

    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 at src/service.ts:1080:

    && action.includes(`<Arguments>${taskXmlString(`/b /nologo "${launcher}"`)}</Arguments>`)

    taskXmlString() (src/service.ts:886-893) escapes " to &quot;, so the needle is <Arguments>/b /nologo &quot;C:\...\launcher.vbs&quot;</Arguments>. Task Scheduler exports that element with the quotes canonicalized back to literal ", so includes() 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 raw includes() above.

    Consequence chain matches your report: registrationHealthy: false at :1107 latches stale, which drops viable/startable, so ocx abandons the scheduler backend and launches the proxy directly. Re-running ocx service install elevated 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 (&quot; &amp; &lt; &gt; &apos;) before comparing, rather than escaping our side of the comparison. That is the same shape as the :1042 helper — 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.

  3. luvs01 commented on Jul 28, 2026

    @luvs01
    Collaborator

    Thanks for confirming the root cause. I prototyped and tested a narrowly scoped two-file patch locally against the current dev head (ca7b1049); I have not opened a PR.

    Would a PR with the following shape be welcome?

    • Replace only the raw <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.
    • Keep the existing command, trigger, principal, and settings checks unchanged.
    • Add regressions that accept both escaped and Task Scheduler-canonicalized literal quotes (including an &amp; launcher path), while still rejecting a genuinely foreign launcher even when the expected text appears in a comment, CDATA section, or nested element.
    • Decode only once, so double-encoded input is not accidentally treated as the expected launcher.

    Local checks on the current dev base:

    • bun test tests/service.test.ts: 42 passed, 0 failed
    • bun x tsc --noEmit: passed
    • privacy scan: passed

    If a parsed-XML or shared-helper approach would fit the project better, I can adjust the proposed shape before any PR is opened.

  4. self-assigned this
    on Jul 28, 2026
  5. lidge-jun commented on Jul 29, 2026

    @lidge-jun
    Owner

    Fixed on dev in 438b8dc.

    Your diagnosis held on every point, including that this is distinct from #432. The needle built by taskXmlString() escaped " to &quot;, Task Scheduler exports that element with the quotes canonicalized back to literal ", and the raw includes() at src/service.ts:1080 could 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 &amp;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 to return true fails 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.ts 43 pass / 0 fail, bun run test 5970 pass / 0 fail, typecheck / privacy:scan green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions