Skip to content

Windows: non-elevated ocx service install fails because LogonTrigger omits UserId — "Access is denied" is a misdiagnosis #4425

Closed

Description

@Xxingwan11

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 install

Observed:

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
200

The 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

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

  1. added
    bugSomething isn't working
    cliCLI, config inject, packaging flags
    platformOS/service/tray/ACL (Windows-heavy, not Windows-only)
    serviceService lifecycle (WinSW/launchd/scheduler)
    on Sep 12, 2026
  2. lidge-jun commented on Sep 12, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 76 / 80

    이 이슈는 Windows에서 관리자 권한이 없는 일반 PowerShell로 ocx service install을 하면 Task Scheduler가 Access is denied를 내고, 메시지는 "UAC를 승인하거나 elevated로 다시 실행하라"고 안내하는 버그입니다. 지금 dev(d8277b653, 패키지 2.53.0)의 src/service.ts buildWindowsTaskXml()를 보면 원인 설명이 코드와 정확히 맞습니다.

    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.ts buildWindowsTaskXml LogonTrigger (대략 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이 작성했습니다

  3. added a commit that references this issue on Sep 13, 2026
  4. lidge-jun commented on Sep 13, 2026

    @lidge-jun
    Owner

    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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcliCLI, config inject, packaging flagsplatformOS/service/tray/ACL (Windows-heavy, not Windows-only)serviceService lifecycle (WinSW/launchd/scheduler)

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions