Skip to content

Client machine listener and standalone proxy share the same default loopback port, making management failures misdiagnosable #4972

Closed

Description

@lidge-jun

Client or integration

Other (the bundled ocx CLI of this project)

Area

Proxy and routing

Summary

The connected-client machine listener and the standalone proxy both default to the same loopback
port, and both answer /healthz with an opencodex identity. The CLI's liveness probe cannot
tell them apart, so on a client machine every management-backed ocx subcommand is addressed to
a listener that structurally serves no management route.

// src/client/machine-listener.ts:81-82
port: port ?? config.port ?? 10100,
hostname: "127.0.0.1",

That listener rejects every /api/* and /v1/* path (src/client/machine-listener.ts:51) and
answers the rejection with a 404 whose body is {"error":"not_found","method":…,"path":…}
(src/client/machine-listener.ts:38). The real management handler's unknown-id answer is
{"error":"not found"} (src/server/management/model-routes.ts:789). The two differ by one
character — an underscore against a space — which is not a difference an operator can be expected
to notice.

The collision is the root enabler rather than the parser gap on top of it. PR #4959 makes the CLI
read the role discriminator the client listener already publishes, which fixes the diagnosis;
it does not stop the two listeners from claiming the same port in the first place, and a shared
default is what puts an operator in front of the ambiguous 404 at all.

This was found while investigating #4662, where the reporter concluded that an unrelated relay on
port 10101 was occupying "the management port". That diagnosis was wrong in an instructive way:
there is no second management port anywhere in the tree, management rides the same listener, and
findLiveProxy had simply resolved the client listener. A collision that makes a whole class of
failures misdiagnosable is worth recording on its own, separately from the one misdiagnosis it
produced.

Reproduction

Static, from source at dev a0f611d4aceb9476d44268e43722273b7b211846:

  1. Run a connected-client machine listener with no explicit port. It binds 127.0.0.1:10100.
  2. findLiveProxy probes /healthz; isOpencodexHealthz (src/server/proxy-liveness.ts:105-110)
    accepts it because service === "opencodex", and never reads role.
  3. Any management-backed subcommand — ocx models edit, and equally system, access,
    provider, combo, alias — sends its request there through runtimeRequest.
  4. The listener answers the not-served-here 404, which responseMessage
    (src/cli/runtime-api.ts:71-75) renders as the bare token not_found, exiting 4.

Version

dev at a0f611d4aceb9476d44268e43722273b7b211846; originally observed on 2.52.0 in #4662.

Operating system

Platform-independent.

Provider and model

Not provider-specific.

Logs or error output

GET/PUT http://127.0.0.1:10100/api/custom-models/<uuid>
-> 404 {"error":"not_found","method":"PUT","path":"/api/custom-models/<uuid>"}

Screenshots and supporting files

None.

Redacted configuration

A client-role installation with no explicit port override, so both the client listener default
and the standalone proxy default resolve to config.port ?? 10100.

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
    proxyHTTP proxy, routing, reverse-proxy / management auth
    on Sep 17, 2026
  2. lidge-jun commented on Sep 18, 2026

    @lidge-jun
    OwnerAuthor

    Assessed against dev at 11bc4f708c by static source reading. The misdiagnosis this issue is
    about is closed at the tip, and the shared default port is not a defect that can be fixed in
    isolation.
    No local verification was run for this; it is source reasoning against the commit named
    above.

    The diagnosis half is fixed

    proxyIdentityAt reads the discriminator today and every findLiveProxy return carries it
    (src/server/proxy-liveness.ts:175, and the runtime-record and configured-port paths at :241 and
    :281):

    const role = typeof body?.role === "string" ? body.role : undefined;
    return { pid, ...(version === undefined ? {} : { version }), ...(role === undefined ? {} : { role }) };

    The client listener publishes it (src/client/machine-listener.ts:87), and management callers now
    refuse that listener before sending anything (src/cli/runtime-api.ts:68):

    if (live.role === "client") {
      throw new RuntimeApiError(clientRoleManagementRefusal(live.port), 503, null);
    }

    The refusal text names the hub as where custom-model edits are made and gives the local alternative
    (edit customModels in config.json, then ocx sync), rather than the bare not_found token. There is
    also a fallback for an explicitly supplied base URL that still reaches the listener's structured 404,
    which now reads as "this listener does not serve METHOD PATH … it is not a missing record"
    (src/cli/runtime-api.ts:103). tests/cli/cli-transport-honesty.test.ts:346 asserts zero bytes sent,
    status 503, and exit 1.

    So the reported path — bare not_found, exit 4 — is no longer reachable through the normal
    management client. The one-character not_found / not found difference no longer has to be noticed
    by anyone.

    The collision is a role alternative, not two listeners

    The two listeners are not concurrent. ocx start reads durable client state and returns into the
    client runtime instead of ever reaching startServer (src/cli/index.ts:426), and
    structure/gui-and-management-api.md:203 states the contract directly: a connected client machine
    runs the machine listener instead of the standalone server, and there is no second management port
    on such a machine. Nothing binds 10100 twice.

    Why a distinct default port is not the minimal fix

    There is no machine-listener port to change. OcxConfig has one port (src/types/config.ts:369),
    the client runtime derives its preferred port from it (src/client/runtime.ts:61), and remote
    pairing binds the grant to that value
    : the connecting client sends Origin: http://localhost:<its configured port> (src/client/connect.ts:167), and the hub assumes http://localhost:10100 for an
    unconfigured client and warns that a different port requires ocx config set port on the other
    machine (src/cli/hub.ts:16 and :198). Changing only the listener default breaks that contract;
    changing config.port moves the standalone proxy too and separates nothing.

    Making isOpencodexHealthz reject role: "client" would also be wrong in the other direction: stop,
    orphan cleanup, and duplicate-start protection all have to keep seeing that process. The boundary the
    tip implements is the right one — liveness accepts and carries the role, management callers refuse
    it.

    #5015's probePortOwner (src/server/proxy-liveness.ts:315) is orthogonal and untouched by any of
    this: it answers "who holds this exact port" for the start path, not "which role is this".

    Suggested disposition

    Close this as resolved by the role discriminator, with the collision recorded rather than fixed.
    Genuinely separating the two default ports is a protocol change — a new persisted field, hub-invite
    negotiation, migration for already-issued pairing assumptions, discovery and GUI-open behavior — and
    deserves its own issue if you want it, rather than being carried as a bug on this one.

    What static source cannot establish: whether the reporter ran this SHA, an older installed CLI, or a
    stale service process. That needs reporter-side version and process evidence plus the live
    /healthz body.

  3. lidge-jun commented on Sep 18, 2026

    @lidge-jun
    OwnerAuthor

    Status update, because two of the three layers you identified have landed and the issue should be narrowed to what is actually left.

    Landed: the diagnosis. #4959 (b5a08750277e983ae9704b5cbf1aaf10141db7dd) makes the CLI read the role discriminator the client listener already publishes, so a healthy client-role listener is no longer indistinguishable from a missing management route. #4662 is closed against it, with the scope corrected to every management-backed subcommand on a client machine rather than ocx models edit alone.

    Landed: identity-checked port ownership. #5015 (52f2b490e0b57b1425354ffa1d5cd9fe49640e04) added probePortOwner in src/server/proxy-liveness.ts, which asks who holds a specific port independently of the pid file and the runtime-port record, on both loopback families. It was written for #5004, but it is the mechanism that makes an occupant identifiable rather than merely present.

    Not landed: the collision itself, which is your actual point and the one I agree is the root enabler. Both listeners still default to the same loopback port, so an operator is still put in front of the ambiguity before any of the above gets a chance to explain it.

    Keeping this open for that. Worth saying why it has not simply been changed: a different default for the client listener is a behaviour change for every existing client install, so it needs a migration story rather than a one-line edit — what happens to a client already bound to 10100, and whether the standalone proxy or the client listener is the one that should move.

    Your observation about how #4662 was misdiagnosed is the part I would most like preserved here. The reporter concluded an unrelated relay on 10101 was occupying "the management port", when there is no second management port in the tree at all and findLiveProxy had simply resolved the client listener. A collision that makes a whole class of failures misdiagnosable is worth more attention than the individual misdiagnosis, which is exactly the argument for fixing this rather than only its symptoms.

  4. lidge-jun commented on Sep 18, 2026

    @lidge-jun
    OwnerAuthor

    Closing this, and I want to engage with the argument you made rather than close it on the half it pre-emptively rejects.

    You wrote that the collision is the root enabler and that #4959 fixes only the diagnosis. The first half does not hold the way it reads, and that is what changes the answer: two processes cannot both bind 127.0.0.1:10100. A shared default cannot produce two live listeners — it can only produce ambiguity about which single listener is there. So the harm was never contention for the port; it was that the prober could not name what it had found.

    That is now named, everywhere it matters. runtimeBaseUrl refuses a client-role listener before any management request is composed, with a message that says what the listener serves and where to make the change instead. Every management-backed subcommand the issue lists reaches it: models-runtime.ts, access.ts, provider-runtime.ts, combo.ts, alias.ts and system-command.ts all go through runtimeRequest, which resolves its base URL through that function. The opaque not_found and exit 4 are unreachable on that path.

    tests/cli/cli-transport-honesty.test.ts pins both directions, including the one that matters for existing installs: a listener reporting no role is still used, so a proxy whose /healthz predates the field is not refused.

    On moving the client default off 10100: it would buy nothing the discriminator does not already deliver, and it would cost something real. A client install's dashboard and its connected peers address that port today, so changing it breaks working configurations to remove an ambiguity that is already resolved. If a concrete case turns up where the role discriminator is not enough — a path that reaches the client listener without passing runtimeBaseUrl — that is worth reopening on, and it would be a different and more specific bug than this one.

    The misdiagnosis in #4662 that prompted this is worth keeping recorded: there is no second management port anywhere in the tree, management rides the same listener, and findLiveProxy had simply resolved the client one.

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 flagsproxyHTTP proxy, routing, reverse-proxy / management auth

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions