Skip to content

[Bug]: every stop refusal is reported as a CODEX_HOME ownership mismatch, hiding respawnable_service and looping the operator #4169

Description

@yeongjunyoo

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

  1. On Windows, install the service backend (Task Scheduler) so the proxy is respawnable.
  2. Run ocx update (or ocx stop) from the same CODEX_HOME the service was installed with.
  3. 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:

  1. 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.
  2. 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

  • I searched existing issues and documentation.
  • This request describes a concrete OpenCodex workflow rather than merely naming a desired technology.
  • I removed secrets and personal data.

Activity

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 workingproxyHTTP proxy, routing, reverse-proxy / management authserviceService lifecycle (WinSW/launchd/scheduler)

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions