Repository navigation
Description
Client or integration
Codex CLI
Area
Service lifecycle
Summary
On Windows, a fresh ocx service install from a non-elevated shell fails with
Service install cleanup failed: Windows access denied while running Task Scheduler.
Command: schtasks /create /tn opencodex-proxy /xml <stage>\task.xml
Approve the Windows UAC prompt to install the background service, or run `ocx service install` from an elevated PowerShell window.
The access-denied diagnosis is misleading, and elevation is not the actual cause.
Root cause: buildWindowsTaskXml() emits a <LogonTrigger> with no <UserId>:
<Triggers>
<LogonTrigger>
<Enabled>true</Enabled>
</LogonTrigger>
<SessionStateChangeTrigger>
<Enabled>true</Enabled>
<UserId>{SID}</UserId>
<StateChange>SessionUnlock</StateChange>
</SessionStateChangeTrigger>
...Task Scheduler interprets an unscoped logon trigger as "any user's logon", which a non-elevated user is not permitted to register, so schtasks /create returns ERROR: Access is denied. The adjacent SessionStateChangeTrigger in the same template already emits ${sessionUserIdElement} (with the explicit comment at src/service.ts:2305-2311 explaining exactly why the scope must be explicit). <LogonTrigger> simply never uses it.
This is an internal inconsistency, not a privilege limitation. The principal is InteractiveToken + LeastPrivilege (same user, no elevation required by design), and an identical task registers successfully without elevation once <UserId> is present.
Expected: <LogonTrigger><Enabled>true</Enabled><UserId>{SID}</UserId></LogonTrigger> — creation succeeds as a normal user.
Reproduction
Two independent confirmations. All commands were run from a non-elevated PowerShell 7 (7.6.6). No UAC prompt was involved.
1. Minimal A/B on the shipped XML shape. Take the exact trigger block buildWindowsTaskXml() produces, and change only the <LogonTrigger>:
SHIPPED: LogonTrigger WITHOUT UserId exit=1 ERROR: Access is denied.
FIXED: LogonTrigger WITH UserId exit=0 SUCCESS
Everything else — InteractiveToken, LeastPrivilege, the three SessionStateChangeTrigger entries, the wscript.exe action — is byte-identical between the two runs.
2. Full ocx service install on a clean machine (no pre-existing task):
ocx service installObserved:
Service install cleanup failed: Windows access denied while running Task Scheduler.
Command: schtasks /create /tn opencodex-proxy /xml C:\Users\<user>\AppData\Local\Temp\opencodex-service-stage-<rand>\task.xml
Approve the Windows UAC prompt to install the background service, or run `ocx service install` from an elevated PowerShell window.
ocx service status afterwards:
service not installed (Task Scheduler); OpenCodex proxy is running independently on port 10100.
3. Isolation matrix (all non-elevated, schtasks /create /xml on an isolated probe task name; each run used a Task version="1.4" document otherwise identical to the shipped template):
empty <Triggers> exit=0
LogonTrigger, no UserId exit=1 Access is denied
LogonTrigger, with UserId exit=0
SessionStateChangeTrigger, no UserId exit=1 Access is denied
SessionStateChangeTrigger, with UserId exit=0
LogonTrigger(no UserId) + SSCT(with) exit=1 Access is denied
LogonTrigger(with) + 3x SSCT(with) exit=0
The failure tracks the missing <UserId> exactly, in both trigger types, and disappears the moment a UserId is present. (The SessionStateChangeTrigger-without-UserId row is also why the in-code comment exists: an unscoped recovery trigger is both rejected here and rejected by the live validator.)
Impact of the failed install: none beyond the missing service. The proxy is unharmed — installFreshWindowsSchedulerSafely() registers before it stops the tracked proxy, so the pre-existing proxy (PID unchanged) kept serving http://127.0.0.1:10100/healthz throughout, and the install rolled back cleanly.
Version
2.52.0
Operating system
Windows 11 Pro Insider Preview, version 10.0.26340, x64; PowerShell 7.6.6
Logs or error output
$ ocx service install
Service install cleanup failed: Windows access denied while running Task Scheduler.
Command: schtasks /create /tn opencodex-proxy /xml C:\Users\<user>\AppData\Local\Temp\opencodex-service-stage-<rand>\task.xml
Approve the Windows UAC prompt to install the background service, or run `ocx service install` from an elevated PowerShell window.
# exit code 1
$ ocx service status
service not installed (Task Scheduler); OpenCodex proxy is running independently on port 10100.
Diagnostics: logs: C:\Users\<user>\.opencodex\service.log
$ curl -s -o NUL -w "%{http_code}" http://127.0.0.1:10100/healthz # proxy unaffected
200The same defect is present on main — src/service.ts buildWindowsTaskXml() still emits <LogonTrigger> without <UserId>.
Suggested fix
Reuse the existing scoped-user element for the logon trigger, exactly as the sibling session trigger does:
<Triggers>
<LogonTrigger>
<Enabled>true</Enabled>
+ <UserId>${sessionUserIdElement}</UserId>
</LogonTrigger>sessionUserIdElement is already computed a few lines above src/service.ts:2309-2311 and is already treated as a required production value (sessionTriggerUserId = resolvedWindowsTaskSid() at src/service.ts:2382; the validator rejects an unscoped recovery trigger). The logon trigger should consume the same value, so the UserId is present on the real install path and the optional-parameter comment stays accurate.
A regression test that asserts the generated XML contains a <UserId> inside <LogonTrigger> (mirroring the existing session-trigger assertion) would lock this in.
But please check the intent first. If an unscoped "any user logon" trigger is genuinely desired here, then the elevation requirement is intentional and only the error message is wrong — it should not tell the user to approve UAC when the XML is what needs to change. The asymmetry with SessionStateChangeTrigger is why I suspect the former.
Relationship to existing reports
- Windows: bare ocx service on an already-installed service fails with WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED instead of repairing/restarting #2287 (bare
ocx serviceon an already-installed service → access-denied) reports the same symptom but concludes it is expected becauseschtasks /create /fneeds elevation. The A/B above shows a non-elevated create succeeds when<UserId>is present, so that conclusion is the misdiagnosis this report is about; Windows: bare ocx service on an already-installed service fails with WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED instead of repairing/restarting #2287's repair/restart routing request is still valid on its own. - Windows (non-English locales): fresh
ocx service installnever triggers the UAC elevated fallback — zh-CN "拒绝访问" access-denied is misclassified because the locale-independent fallback is gated behind/f#4182 and Windows: v2.40.0 still misclassifies a valid scheduler task for non-ASCII account names #3320 both fix the classification of an access-denied result (localized text, elevation fallback). This defect is upstream of classification: the shipped XML is not registrable by a normal user in the first place, so no classifier fix can install it without elevation.
Redacted configuration
{ "version": 2, "backend": null, "note": "fresh install attempt; no scheduler service registered, no prior service-state" }No credentials, tokens, SIDs, account names, or private paths are included above.
Checks
- I searched existing issues and documentation.
- I removed secrets, tokens, account details, request credentials, and personal data.
Activity
lidge-jun commentedon Sep 12, 2026
리뷰 · 우선순위 76 / 80
이 이슈는 Windows에서 관리자 권한이 없는 일반 PowerShell로
ocx service install을 하면 Task Scheduler가Access is denied를 내고, 메시지는 "UAC를 승인하거나 elevated로 다시 실행하라"고 안내하는 버그입니다. 지금dev(d8277b653, 패키지 2.53.0)의src/service.tsbuildWindowsTaskXml()를 보면 원인 설명이 코드와 정확히 맞습니다.LogonTrigger는<Enabled>true</Enabled>만 넣고<UserId>가 없습니다. 바로 아래SessionStateChangeTrigger세 개(RemoteConnect / SessionUnlock / ConsoleConnect)는 같은 함수의sessionUserIdElement로 설치 계정 SID를 넣습니다. 주석(대략 2305-2311행)도 "UserId를 빼면 아무 계정의 세션 변화에나 반응한다. 그래서 설치 계정 SID를 넘긴다. 라이브 validator는 unscoped recovery trigger를 거절한다"고 이미 적혀 있습니다. 그런데 로그온 트리거만 그 값을 안 씁니다. Windows는 UserId 없는 LogonTrigger를 "아무 사용자 로그온"으로 해석하고, 일반 사용자는 그걸 등록할 권한이 없어서schtasks /create가 access denied를 냅니다. Principal은InteractiveToken+LeastPrivilege라서, 설계상 elevated가 필요 없는 설치입니다.제보자의 A/B가 결정적입니다. XML에서 LogonTrigger에 UserId만 넣으면 같은 비관리자 셸에서 exit 0이고, 빼면 exit 1입니다. SessionStateChangeTrigger도 같은 규칙입니다. 설치가 실패해도
installFreshWindowsSchedulerSafely()가 프록시를 멈추기 전에 등록을 시도해서, 이미 돌던 프록시는 살아 있고 healthz 200이 유지됩니다. 증상은 "서비스만 안 깔림"입니다.관련 이슈와도 맞습니다. 닫힌 #2287은 같은 access-denied를 "elevated가 필요하다"로 읽었는데, 이번 A/B는 그 결론이 오진이라고 보여 줍니다(#2287의 repair/restart 라우팅 요청 자체는 별개로 유효). #4182 / #3320은 거부 결과 분류를 고친 것이고, 여기는 분류 이전 — 출하 XML이 일반 사용자에게 등록 자체가 안 되는 문제입니다.
버전은 제보 2.52.0이고,
main/dev의src/service.ts에도 동일 형태가 남아 있습니다. 라벨(bug / cli / platform / service)과도 맞습니다.라인 - 해당 없음(이슈; 수정 PR 아직 없음)
src/service.tsbuildWindowsTaskXmlLogonTrigger (대략 2318-2320행) -sessionUserIdElement를 넣지 않아 비관리자schtasks /create가 access denied가 납니다src/service.ts로그온 헬스 검사 (대략 2607-2613행) - LogonTrigger 존재와 Enabled만 보고, SessionStateChangeTrigger와 달리 UserId 스코프를 요구하지 않습니다. 회귀 테스트도 LogonTrigger 안의 UserId를 assert하지 않으면 다시 빠질 수 있습니다에러 메시지- XML이 잘못된데도 UAC/elevated를 안내해서 원인 파악을 빗나갑니다. UserId를 고치면 메시지 수정은 부차적입니다#2287 - "create는 elevated 필요" 오진을 전제로 한 문서/코멘트가 남아 있으면 이 이슈에 맞춰 고쳐야 합니다
메인테이너의 판단이 필요한 지점
너의 추천
유효한 Windows 설치 블로커로 두고, 제안 diff 그대로 고치세요.
LogonTrigger안에 기존sessionUserIdElement를 넣고,tests/service/service.test.ts에 LogonTrigger UserId 존재를 SessionStateChangeTrigger와 같은 수준으로 assert하세요. 라이브 validator에도 LogonTrigger 스코프를 요구하면 더 안전합니다. types/config 무효화·중복 close 대상이 아닙니다. 작은 핫픽스 PR을dev에 바로 넣는 쪽을 추천합니다.이 댓글은 grok-bot이 작성했습니다
Merge pull request #4486 from lidge-jun/codex/260913-carry-i1-4442
lidge-jun commentedon Sep 13, 2026
Fixed on dev via #4483, merged inside lane I1's tip #4486 (merge commit a3ca64f, verified as an ancestor of origin/dev).
You were right that the diagnosis was the other half of the bug. The generated LogonTrigger omitted UserId, and the resulting Access is denied was reported as an elevation problem, which sent operators to re-run elevated instead of fixing the trigger. UserId is now emitted and the guidance no longer blames elevation.
One deliberate limit: registrations created before this fix carry an unscoped LogonTrigger and keep working, and the live validator does not mark them unhealthy. Flagging them would strand them in preserved-for-manual-review rather than repair them.
Windows evidence is real rather than implied: platform-windows never runs on a pull request, so the lane obtained workflow_dispatch run 34744707214 of ci.yml with lane=all on the exact merged head, where all six windows shards executed and passed.
Closing manually because this merges into dev rather than the default branch.
fix(service): scope the Windows logon trigger and stop blaming elevat…
fix(service): keep the scoped Windows logon trigger after the split
merge origin/dev into the god-file round 1 tip
fix(service): keep the scoped Windows logon trigger after the split
fix(service): scope the Windows logon trigger and stop blaming elevat…
Merge pull request lidge-jun#4486 from lidge-jun/codex/260913-carry-i…
merge origin/dev into the god-file round 1 tip