Client or integration
ocx CLI on Windows (Task Scheduler service backend). Observed on an installed 2.43.0; the code path is unchanged on 2.49.0.
Area
Service lifecycle
Summary
When the proxy refuses a stop, stopProxy reports every refusal as a CODEX_HOME/OPENCODEX_HOME ownership mismatch. The management API distinguishes three different 409 refusals, but the CLI collapses them into one message that names a cause the operator can neither confirm nor act on.
In my case the homes matched exactly, and the server's actual answer was respawnable_service. Because the printed message said the opposite, I spent three attempts re-exporting CODEX_HOME before probing POST /api/stop directly and seeing the real code.
The advice also forms a loop for the operator:
POST /api/stop → 409 respawnable_service: "the stop must be run by ocx stop"
ocx stop → "a service installed under a different CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home."
Each command points at the other, and neither names the Task Scheduler wrapper that is actually refusing.
Reproduction
- On Windows, install the service backend (Task Scheduler) so the proxy is respawnable.
- Run
ocx update (or ocx stop) from the same CODEX_HOME the service was installed with.
- Observe the printed failure.
Observed:
🛑 Service manager stopped.
❌ Failed to stop proxy (PID <pid>).
The running proxy refused to stop: a service installed under a different
CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home.
The server's real answer, probed directly with the admin token:
$ curl -s -o body -w "HTTP %{http_code}\n" -X POST http://127.0.0.1:10100/api/stop -H "Authorization: Bearer <token>"
HTTP 409
$ cat body
{"success":false,"code":"respawnable_service","message":"This proxy is managed by a Task Scheduler wrapper that can respawn it, so the stop must be run by `ocx stop`, which verifies the respawn window. Nothing was changed."}
Ownership was not mismatched. ~/.opencodex/service-state.json:
{
"version": 2,
"codexHome": "C:\\Users\\<user>\\.codex",
"opencodexHome": "C:\\Users\\<user>\\.opencodex",
"backend": "scheduler"
}
…and the command ran with CODEX_HOME=C:\Users\<user>\.codex, i.e. equal to state.codexHome.
Version
2.43.0 (observed). Re-checked against 2.49.0 and dev (2.50.0): the message strings and branching are unchanged.
Operating system
Windows 11 (Task Scheduler service backend). Codex runtime 0.153.4.
Expected
The refusal should name the cause the server actually reported. A respawnable_service or service_state_unknown refusal should not be described as an ownership mismatch, and the recommended next command should not be the command the operator just ran.
Where it comes from
src/server/management-api.ts returns three distinct refusal codes for POST /api/stop:
respawnable_service
self_unload_service
service_state_unknown
src/lib/process-control.ts keeps the server text in lastRefusalMessage and uses it when present, but the fallback hardcodes the ownership wording for all three, and the surrounding comments state ownership as fact:
// The proxy refused on purpose (foreign service owns it). Forcing would strip shared
// config while that service keeps the proxy alive.
throw new ProxyOwnershipRefusedError(
lastRefusalMessage
?? "The running proxy refused to stop: a service installed under a different "
+ "CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home.",
);
The same assumption appears at the 409 branch of stopProxyGracefully ("a service installed under another home owns it and would respawn it anyway") and in the ProxyOwnershipRefusedError name itself.
So there are two separable defects:
- The fallback asserts a specific wrong cause. Whenever
lastRefusalMessage is absent, the operator is told about CODEX_HOME regardless of which of the three refusals occurred.
- In my run the server message was present but not shown. The printed text was the hardcoded fallback even though
POST /api/stop returned a non-empty message moments later under the same conditions. I have not isolated why lastRefusalMessage was null on that path, so I am reporting (1) as the actionable defect and (2) as an observation, not a diagnosis.
What I am not claiming
- I did not reduce (2) to a standalone reproducer, and I did not verify it on 2.49.0 — only that the message strings and branching are unchanged there.
- The eventual workaround on my side was terminating the proxy process directly and letting the scheduler respawn it on the new package, which is outside what this report is about.
Suggested direction
Carry the server's code alongside the message and select the fallback wording from it, so a refusal without a body still names the right family (respawnable wrapper / self-unload service / unreadable scheduler state) rather than defaulting to ownership. Renaming ProxyOwnershipRefusedError to something cause-neutral would keep call sites honest.
Happy to open a PR against dev if this direction looks right.
Checks
Client or integration
ocxCLI on Windows (Task Scheduler service backend). Observed on an installed 2.43.0; the code path is unchanged on 2.49.0.Area
Service lifecycle
Summary
When the proxy refuses a stop,
stopProxyreports every refusal as aCODEX_HOME/OPENCODEX_HOMEownership mismatch. The management API distinguishes three different 409 refusals, but the CLI collapses them into one message that names a cause the operator can neither confirm nor act on.In my case the homes matched exactly, and the server's actual answer was
respawnable_service. Because the printed message said the opposite, I spent three attempts re-exportingCODEX_HOMEbefore probingPOST /api/stopdirectly and seeing the real code.The advice also forms a loop for the operator:
POST /api/stop→ 409respawnable_service: "the stop must be run byocx stop"ocx stop→ "a service installed under a different CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home."Each command points at the other, and neither names the Task Scheduler wrapper that is actually refusing.
Reproduction
ocx update(orocx stop) from the sameCODEX_HOMEthe service was installed with.Observed:
The server's real answer, probed directly with the admin token:
Ownership was not mismatched.
~/.opencodex/service-state.json:{ "version": 2, "codexHome": "C:\\Users\\<user>\\.codex", "opencodexHome": "C:\\Users\\<user>\\.opencodex", "backend": "scheduler" }…and the command ran with
CODEX_HOME=C:\Users\<user>\.codex, i.e. equal tostate.codexHome.Version
2.43.0 (observed). Re-checked against 2.49.0 and dev (2.50.0): the message strings and branching are unchanged.
Operating system
Windows 11 (Task Scheduler service backend). Codex runtime 0.153.4.
Expected
The refusal should name the cause the server actually reported. A
respawnable_serviceorservice_state_unknownrefusal should not be described as an ownership mismatch, and the recommended next command should not be the command the operator just ran.Where it comes from
src/server/management-api.tsreturns three distinct refusal codes forPOST /api/stop:respawnable_serviceself_unload_serviceservice_state_unknownsrc/lib/process-control.tskeeps the server text inlastRefusalMessageand uses it when present, but the fallback hardcodes the ownership wording for all three, and the surrounding comments state ownership as fact:The same assumption appears at the 409 branch of
stopProxyGracefully("a service installed under another home owns it and would respawn it anyway") and in theProxyOwnershipRefusedErrorname itself.So there are two separable defects:
lastRefusalMessageis absent, the operator is told aboutCODEX_HOMEregardless of which of the three refusals occurred.POST /api/stopreturned a non-emptymessagemoments later under the same conditions. I have not isolated whylastRefusalMessagewas null on that path, so I am reporting (1) as the actionable defect and (2) as an observation, not a diagnosis.What I am not claiming
Suggested direction
Carry the server's
codealongside the message and select the fallback wording from it, so a refusal without a body still names the right family (respawnable wrapper / self-unload service / unreadable scheduler state) rather than defaulting to ownership. RenamingProxyOwnershipRefusedErrorto something cause-neutral would keep call sites honest.Happy to open a PR against
devif this direction looks right.Checks