Skip to content

[WRONG BRANCH] fix(service): decode schtasks output with the Windows text decoder (#4691) - #4749

Merged
lidge-jun merged 7 commits into
codex/win1-update-teardownfrom
codex/win2-schtasks-gbk
Sep 16, 2026
Merged

lidge-jun merged 7 commits into
codex/win1-update-teardownfrom
codex/win2-schtasks-gbk

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Summary

On a zh-CN host (ACP/OEMCP 936) with a CJK account name, ocx service repair and the dashboard repair/install buttons failed against a registration OpenCodex had created itself:

Service repair failed: Task Scheduler registration is not a recognized legacy OpenCodex definition; it was preserved for manual review.

Redirected schtasks /query /xml output follows the console output code page of the spawning process tree, not the XML document encoding. In any 936 context — including the no-console background service on a zh-CN host — those bytes are GBK. decodeSchtasksOutput probed UTF-16 and then fell back to a plain UTF-8 decode, so the CJK account name inside <SessionStateChangeTrigger><UserId> became U+FFFD. The correctly resolved expected identity [SID, MACHINE\<name>] then never matched the trigger scope, windowsTaskRegistrationHealthy returned false, and repair aborted at its recognition gate. The same mojibake rolled back fresh installs at post-create verification.

The fix is entirely in byte decoding, before any XML is parsed. decodeSchtasksOutput now delegates to decodeWindowsTextBytes (src/lib/windows-text.ts), the decoder this project already built for this class: UTF-16, then strict UTF-8, then the locale's legacy code page. It already fixed the sibling whoami/PowerShell decode in src/lib/windows-user-principal.ts (#2914, and #722 for CP949); this call site was the last one still ending in a lossy UTF-8 decode. The decoder selects the code page from the process locale rather than a console handle, which is why it also works in the no-console service context.

Task ownership is deliberately untouched. windowsTaskTriggerScopeAcceptable still requires an exact identity match, and the added tests assert that a different account and the mojibake spelling are both still rejected. Forgiving a replacement character there would let two different non-ASCII accounts collapse to the same value, which is a worse failure than the refusal it replaces.

Delegating also removes a latent UTF-16BE edge: the old local copy allocated buffer.length - 2 bytes for an odd-length payload and left a trailing uninitialized byte, while the shared decoder rounds the payload down.

Stacked on #4746, so this PR targets codex/win1-update-teardown. Retarget to dev once the parent lands.

Closes #4691

Verification

No local test suite, single test file, typecheck, build or install was run — the repository owner prohibits it for this lane. Local verification is explicitly NOT RUN. Hosted CI on the lane tip is the only execution evidence, and the Windows job there is the only platform evidence that exists for this change.

Static verification performed:

  • Traced the failing path: repairService -> statusWindowsXml -> schtasks -> querySchtasks -> runFile (which correctly captures encoding: "buffer") -> decodeSchtasksOutput, then resolveWindowsTaskDiagnosticUserId -> windowsTaskRegistrationHealthy -> windowsTaskHasSessionRecoveryTriggers -> windowsTaskTriggerScopeAcceptable -> taskXmlDecodedValueEquals. Confirmed the identity comparison is exact and fails closed, and that only the decode is wrong.
  • Confirmed the other consumer, the post-create verification in src/service/windows-ops.ts, reads through the same decoder, so the install rollback path is fixed by the same change.
  • Confirmed decodeWindowsTextBytes reproduces the existing UTF-16LE/BE and BOM handling, so the delegation is behaviour-preserving for the encodings this function already handled.
  • Audited the remaining call sites: src/service/windows-ops.ts:279 reads the staged UTF-16LE task XML through this same function and is unaffected. Other Windows child-output decoders (readWindowsPrincipalSource, icacls in windows-secret-acl.ts, sc.exe/WinSW in winsw.ts, elevation diagnostics) still use raw UTF-8. Their expected values are ASCII enum-like tokens rather than account names, so they are out of scope here rather than overlooked; folding them in would need separate behaviour review.

Regression tests added to tests/windows/windows-scheduler-install-verification.test.ts (they run in CI, not locally):

  • Healthy task XML whose trigger <UserId> is MACHINE\张三, encoded with literal CP936 bytes (0xD5C5 0xC8FD) for the same reason tests/windows/windows-text-decoding.test.ts uses literal hex: encoding the fixture with the decoder under test would assert nothing. The test asserts the decode is lossless and windowsTaskRegistrationHealthy accepts it.
  • The regression itself: the historical UTF-8 decode of the same bytes contains U+FFFD and is rejected by the health check.
  • Ownership is not relaxed: a different account and the mojibake spelling are both still rejected against the correctly decoded XML.
  • A UTF-8 task document under a zh-CN locale still decodes as UTF-8, because the strict UTF-8 attempt runs before any code-page guess. Windows service management breaks on non-ASCII (Chinese) usernames / UTF-8 codepage: 'ownership could not be proven' and 'registered as .\���� ... rolled back' #4106 was closed as not-planned because that reporter's console was CP 65001; this pins that the fix leaves that case alone.
  • UTF-16LE with and without a BOM, and UTF-16BE, still round-trip.

Not provable without a real zh-CN Windows host, and not claimed: the exact bytes schtasks.exe emits in every no-console service configuration, whether Bun's Windows runtime exposes the expected Intl locale inside the installed service, and end-to-end repair/install/rollback against a real Task Scheduler.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed. No user-facing documentation describes schtasks output decoding; the rationale lives in the function's doc comment.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults. This touches an identity-verification input, so the tests explicitly pin that the trigger-scope ownership check stays exact and that neither a foreign account nor a lossy spelling is accepted. No credential, auth or workflow surface is modified, and nothing new is logged.

Prior-art check

No existing pull request, open or closed, implements this fix. Searched the repository's pull requests by issue number and by implementation signature, and inspected the adjacent antecedents (#3040, #4313, #2918, #3067) — each addresses a different problem and no code is carried from any of them. No Co-authored-by trailer is therefore owed. Recording the check here so the question does not have to be reopened.

…4691) [skip ci]

On a zh-CN host (ACP/OEMCP 936) with a CJK account name, "ocx service repair"
and the dashboard repair/install buttons failed against a registration
OpenCodex had created itself:

  Service repair failed: Task Scheduler registration is not a recognized
  legacy OpenCodex definition; it was preserved for manual review.

Redirected "schtasks /query /xml" output follows the console output code page
of the spawning process tree, not the XML document encoding. In any 936
context -- including the no-console background service on a zh-CN host -- the
bytes are GBK. decodeSchtasksOutput probed UTF-16 and then fell back to a
plain UTF-8 decode, so the CJK account name inside
<SessionStateChangeTrigger><UserId> became U+FFFD. The correctly resolved
expected identity [SID, MACHINE\<name>] then never matched the trigger scope,
windowsTaskRegistrationHealthy returned false, and repair aborted at its
recognition gate. The same mojibake rolled back fresh installs at post-create
verification.

The fix is entirely in byte decoding, before any XML is parsed.
decodeSchtasksOutput now delegates to decodeWindowsTextBytes, the decoder this
project already built for exactly this class (UTF-16, then strict UTF-8, then
the locale's legacy code page). It already fixed the sibling whoami/PowerShell
decode in src/lib/windows-user-principal.ts (#2914, and #722 for CP949); this
call site was the last one still ending in a lossy UTF-8 decode.

Task ownership is deliberately untouched. windowsTaskTriggerScopeAcceptable
still requires an exact identity match, and the tests assert that a different
account and the mojibake spelling are both still rejected. Forgiving a
replacement character there would let two different non-ASCII accounts
collapse to the same value, which is worse than the refusal it replaces.

Delegating also fixes a latent UTF-16BE edge: the old local copy allocated
buffer.length - 2 bytes for an odd-length payload and left a trailing
uninitialized byte. The shared decoder rounds the payload down instead.

Closes #4691
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 16, 2026 02:14
@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • ^dev$
  • ^preview$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: a7600b4f-3598-443e-a52d-1e28f75a6ee7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-16T02:17:18.764593Z 52ec0a5 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Sep 16, 2026
@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 77 / 80

이 PR은 중국어(zh-CN) Windows에서 ocx service repair와 대시보드 설치/수리가 자기 자신이 만든 작업 등록을 “legacy OpenCodex가 아니다”라고 거절하던 버그(#4691)를 고칩니다. 지금 dev HEAD(3070d64d8, 패키지 2.57.0)의 src/service/windows-scheduler.tsdecodeSchtasksOutput은 UTF-16(BOM/휴리스틱)만 본 뒤 나머지를 그냥 UTF-8로 읽습니다. 그런데 리다이렉트된 schtasks /query /xml 바이트는 XML 선언이 아니라 띄운 프로세스 트리의 콘솔 코드 페이지를 따릅니다. zh-CN(ACP/OEMCP 936)에서는 그게 GBK이고, 콘솔 없는 백그라운드 서비스에서도 같습니다. 그래서 <SessionStateChangeTrigger><UserId> 안의 CJK 계정 이름이 U+FFFD로 깨지고, 올바르게 구한 [SID, MACHINE\이름]과 트리거 범위가 안 맞아 windowsTaskRegistrationHealthy가 false가 되며, 수리 인식 게이트에서 멈추고 신규 설치도 post-create 검증에서 롤백됩니다.

고치는 방법은 XML을 손대거나 소유권 비교를 느슨하게 하는 게 아닙니다. 바이트 디코딩만 src/lib/windows-text.tsdecodeWindowsTextBytes(UTF-16 → 엄격 UTF-8 → 로케일 legacy 코드 페이지)로 넘깁니다. 이 디코더는 이미 windows-user-principal.ts의 whoami/PowerShell 쪽(#2914, CP949는 #722)에서 같은 종류의 문제를 고쳤고, 콘솔 핸들이 아니라 프로세스 Intl 로케일로 코드 페이지를 고르기 때문에 no-console 서비스에도 맞습니다. 테스트는 리터럴 CP936 바이트(0xD5C5/0xC8FD)로 GBK 회귀를 고정하고, 다른 계정·모지바케 철자는 계속 거절하며, zh-CN 로케일에서도 엄격 UTF-8이 먼저라 #4106 같은 CP65001 케이스를 깨지 않게 잡아 둡니다. UTF-16LE/BE 경로도 위임 후에도 그대로인지 확인합니다.

현재 dev 기준으로도 이 호출 지점은 아직 로컬 UTF-8 폴백이라, 방향은 맞고 스코프도 좁습니다. 다만 베이스가 dev가 아니라 스택 부모 #4746(codex/win1-update-teardown)이고, 호스티드 CI(특히 Windows)가 아직 안정적이지 않습니다(mergeable_state: unstable, enforce-target pending). PR이 스스로 밝히듯 실제 zh-CN 호스트 e2e는 이 레인에서 증명하지 않았고, 로컬 스위트도 금지 레인입니다.

라인 25~26 (windows-scheduler.ts JSDoc) - 주석 줄 두 곳이 별표 뒤에 공백 없이 이어져 JSDoc 정렬이 깨져 있습니다. 동작에는 영향 없지만 문서 블록만 살짝 흐트러집니다.
경로 tests/windows/windows-scheduler-install-verification.test.ts - 회귀·소유권·UTF-8 우선·UTF-16 유지는 잘 잡혀 있습니다. 다만 실제 schtasks.exe가 no-console 서비스에서 항상 같은 바이트를 내는지, Bun Windows 런타임이 설치 서비스 안에서 기대 Intl 로케일을 노출하는지는 이 PR만으로 닫히지 않습니다.
경로 base codex/win1-update-teardown - dev 직접 머지 대상이 아닙니다. 부모 #4746이 먼저 안착해야 리타겟/머지 순서가 열립니다.
경로 enforce-target / Windows CI - 하이진·라벨은 통과했지만 전체 레인(특히 Windows job)과 타깃 브랜치 강제 검사가 아직 끝나지 않아 지금 바로 dev에 올릴 증거는 부족합니다.

메인테이너의 판단이 필요한 지점

  • #4746이 먼저 dev에 들어간 뒤 이 PR을 dev로 리타겟할지, 아니면 win 스택을 통째로 순서 머지할지
  • 로케일 Intl이 서비스 프로세스에서 기대와 다를 때(드묾) 실패 모드를 추가 관측/로그로 둘지, 지금처럼 디코드 위임만으로 충분한지
  • PR이 의도적으로 남긴 다른 Windows 자식 출력(UTF-8 그대로인 icacls/sc/WinSW 등)을 같은 디코더로 묶는 follow-up을 열지 말지(계정명이 아닌 ASCII 토큰이라 이번 스코프 밖은 타당)

너의 추천
열어 두고 부모 #4746 + 호스티드 Windows CI 그린을 기다린 뒤 dev로 리타겟(또는 스택 순서 머지)하세요. 소유권 비교를 느슨하게 만들지 마세요. Closes #4691은 머지 시 함께 닫히면 됩니다. JSDoc 공백 두 줄은 원하면 같이 고치고, 아니면 무시해도 됩니다.

이 댓글은 grok-bot이 작성했습니다

#4692)

When "ocx service repair" re-registered the task through the elevated
fallback, the spawn failed before UAC ever appeared:

  WindowsElevationError: ENAMETOOLONG: name too long, uv_spawn
      at startPowerShellCommand (src/lib/windows-elevation.ts:560)
      at runWindowsElevatedScheduledTaskRegistration (.../windows-elevation.ts:704)

runWindowsElevatedScheduledTaskRegistration embedded the new task XML and the
expected-existing snapshot as base64(utf16le) inside an inner PowerShell
script, which was then base64(utf16le)-encoded again into -EncodedCommand. Two
base64 layers over UTF-16 cost roughly 14.2 command-line characters per XML
character, and a replacement carries two payloads, so a ~2 KB definition put
the outer command past the Windows limit. On a host where Task Scheduler
exports the trigger scope as an account name the re-register path runs on
every repair, so repair could never exit 0.

Both payloads are now staged to files and the command carries two paths and
two 64-character digests, so its length no longer depends on the size of the
XML at all.

A file an administrator process will read is itself a privilege-escalation
surface, so three properties hold together and none is sufficient alone:

- Access. The staging directory is created fresh by mkdtemp and ACL-hardened
  through the existing hardenSecretDir/hardenSecretPath before anything is
  written into it, so the payload is private from the moment it exists.
- No redirection. Each artifact is inspected with lstat and rejected unless it
  is what it claims to be. Exclusive "wx" creation inside a directory that did
  not exist a moment ago is the atomic step; the explicit check keeps that
  guarantee from resting on a reading of O_EXCL semantics.
- Tamper evidence. The digest covers the exact bytes written, and the elevated
  script reads the file once, hashes what it read, and refuses before decoding.
  An ACL cannot cover this: a process running as the same user has the same
  SID and can rewrite the file, so the digest is what makes a swap during the
  UAC prompt fail closed instead of registering a different definition.

Cleanup runs on every exit -- success, UAC cancellation, a synchronous spawn
failure, a failed digest check, and a partial staging failure -- and a cleanup
error is aggregated with the registration error rather than replacing it.

The original "immutable bytes, never a caller-writable pathname" goal is kept
by different means rather than abandoned, and the replacement precondition is
untouched: the elevated process still re-queries the live registration and
compares it to the verified predecessor before passing -Force.

Payloads are UTF-16LE with no BOM and are decoded straight into
Register-ScheduledTask, so what is hashed is exactly what is registered, with
no trimming step the two sides could disagree about.

Closes #4692
Staging the elevated Task Scheduler XML introduces exactly one new failure of
its own: hardenSecretPath grants the staging account and strips inheritance,
so a split-token elevation of the same user reads the file while an elevation
answered with a DIFFERENT administrator's credentials does not. The inline
form had no such dependency.

The elevated process runs hidden, so nothing it writes survives and only the
exit code crosses back. That made the failure an unexplained non-zero status --
the same undiagnosable shape as the ENAMETOOLONG this change set removes.

The read failure now has its own protocol code, and the parent turns it into a
message that names both the cause and the way out: approve the prompt as the
signed-in user, or run again from a session already elevated as that user. The
code sits outside OCX_ELEVATED_PROTOCOL_CODES, which is the create-and-run
transaction's alphabet, and cannot collide with UAC cancellation.

Whether to widen the ACL to SYSTEM and Administrators is left as a separate
security decision rather than bundled here, because it changes a
security-sensitive module.
…ce suite (#4692)

The file-size ratchet failed: tests/service/service.test.ts has a committed
cap of 4106 lines and the new staging cover pushed it to 4245. The ratchet only
ever lowers baselines, so growing past a cap is the thing it exists to refuse,
not something to re-baseline around.

The cover moves to tests/windows/windows-elevation-spawn.test.ts, which is the
better home anyway: its subject is the elevated registration payload, which is
exactly what these tests exercise. That file has no cap and stays well under
the 2000-line threshold, and the service suite returns to its baseline
unchanged, so no new test file and no test-layout registration are needed.

Also replaces a logical-assignment shorthand in the staging cleanup with the
explicit form the surrounding code already uses. No behaviour change; folded in
here rather than spending a separate CI cycle on it.
Propagates the dev merge from the layer below, including the Windows
desktop-restart fix f79c147 that the lane's Windows evidence needs. Nothing
in this layer changes; cascading keeps this pull request's diff limited to the
schtasks decode.
Brings in the Windows desktop-restart fix f79c147 through the chain, so this
lane's Windows evidence measures this lane.

The previous dispatch at 6a2b148 had windows 2/6 fail with nine assertions,
every one of them in tests/clients/desktop-app-restart-posix.test.ts and none
touching anything this lane changes. The same nine failures, at the same line
numbers, are on the dev-only dispatch 34795291889 from 2026-09-14, which is what
identifies the defect as pre-existing rather than introduced here. The other
eleven shards were green: test 1/4 through 4/4 and windows 1, 3, 4, 5 and 6 of 6.

No [skip ci] here: this is the lane tip, and its run is the gate for all three
layers.
…ion-length

fix(service): stage elevated Task Scheduler XML instead of inlining it (#4692)
@lidge-jun

Copy link
Copy Markdown
Owner Author

Cascading downward. schtasks output on a zh-CN host is decoded through the shared Windows decoder instead of a UTF-8 fallback that mangled GBK bytes.

Evidence at the verified tip 8a1b010 (tree 5fb70432366550aab0177be5255b5097379d7935), from dispatch run 35054231781:

  • Every check-run at this commit concluded success. There is no failing, cancelled or pending check outside the always-skipped matrix placeholders.
  • test 1-4/4 and macos 1-2/2 all completed with conclusion success, confirmed through the check-runs API rather than the check rollup.
  • windows 1/6 through 6/6 all completed with conclusion success. That evidence is required rather than incidental here: every change in this lane is Windows-specific, and the platform-windows job is workflow_dispatch-only, so a pull_request run never exercises it.
  • The current head carries no pull_request run because the three branches were pushed together and the base and head of the tip moved almost simultaneously, so no synchronize event fired. The job sets were compared directly: the dispatch run covers 26 jobs against the pull-request run's 21, and no real job present in a pull-request run is absent from the dispatch. The only difference is a skipped matrix placeholder. The dispatch is a superset, at the exact head.
  • The lane absorbed dev at 5e3029e from the bottom layer upward, so each pull request keeps its own layer diff (13 / 2 / 4 files). Before that absorption, windows 2/6 failed on nine desktop-restart assertions that were live in dev and repaired by fix(codex): repair desktop restart membership and POSIX-only cases on Windows #4564; the same shard passes here.
  • git merge-tree --write-tree origin/dev <tip> reports a clean merge.
  • Ancestry verified so each layer closes as MERGED: win1-update-teardown and win2-schtasks-gbk are both ancestors of this tip.

Chained-child stacks merge top-down, so this lands in the parent branch and cascades to dev. CI evidence transfers by tree identity at each step.

Maintainer integration decision under MAINTAINERS.md / AGENTS.md: a maintainer with maintain or admin access may integrate into dev without a second maintainer approval, recording the decision and exact-head CI evidence.

@lidge-jun
lidge-jun merged commit c7924b8 into codex/win1-update-teardown Sep 16, 2026
10 checks passed
@lidge-jun
lidge-jun deleted the codex/win2-schtasks-gbk branch September 16, 2026 05:03
@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

⏳ DRAFT

  • wrong target branch (codex/win1-update-teardown); retarget to dev.

What to do

  • Retarget this PR to dev — all contributions go to dev.

Its title has been prefixed with [WRONG BRANCH].
Automatic draft conversion failed (token cannot change draft status). Please convert this pull request to a draft manually. The required enforce-target check will keep failing until every issue above is resolved.

@github-actions github-actions Bot changed the title fix(service): decode schtasks output with the Windows text decoder (#4691) [WRONG BRANCH] fix(service): decode schtasks output with the Windows text decoder (#4691) Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant