Repository navigation
Client machine listener and standalone proxy share the same default loopback port, making management failures misdiagnosable #4972
Description
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:
- Run a connected-client machine listener with no explicit port. It binds
127.0.0.1:10100. findLiveProxyprobes/healthz;isOpencodexHealthz(src/server/proxy-liveness.ts:105-110)
accepts it becauseservice === "opencodex", and never readsrole.- Any management-backed subcommand —
ocx models edit, and equallysystem,access,
provider,combo,alias— sends its request there throughruntimeRequest. - The listener answers the not-served-here 404, which
responseMessage
(src/cli/runtime-api.ts:71-75) renders as the bare tokennot_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
lidge-jun commentedon Sep 18, 2026
Assessed against
devat11bc4f708cby static source reading. The misdiagnosis this issue isabout 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
proxyIdentityAtreads the discriminator today and everyfindLiveProxyreturn carries it(
src/server/proxy-liveness.ts:175, and the runtime-record and configured-port paths at:241and:281):The client listener publishes it (
src/client/machine-listener.ts:87), and management callers nowrefuse that listener before sending anything (
src/cli/runtime-api.ts:68):The refusal text names the hub as where custom-model edits are made and gives the local alternative
(
edit customModels in config.json, thenocx sync), rather than the barenot_foundtoken. There isalso 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:346asserts zero bytes sent,status 503, and exit 1.
So the reported path — bare
not_found, exit 4 — is no longer reachable through the normalmanagement client. The one-character
not_found/not founddifference no longer has to be noticedby anyone.
The collision is a role alternative, not two listeners
The two listeners are not concurrent.
ocx startreads durable client state and returns into theclient runtime instead of ever reaching
startServer(src/cli/index.ts:426), andstructure/gui-and-management-api.md:203states the contract directly: a connected client machineruns 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.
OcxConfighas oneport(src/types/config.ts:369),the client runtime derives its preferred port from it (
src/client/runtime.ts:61), and remotepairing binds the grant to that value: the connecting client sends
Origin: http://localhost:<its configured port>(src/client/connect.ts:167), and the hub assumeshttp://localhost:10100for anunconfigured client and warns that a different port requires
ocx config set porton the othermachine (
src/cli/hub.ts:16and:198). Changing only the listener default breaks that contract;changing
config.portmoves the standalone proxy too and separates nothing.Making
isOpencodexHealthzrejectrole: "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 ofthis: 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
/healthzbody.lidge-jun commentedon Sep 18, 2026
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 therolediscriminator 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 thanocx models editalone.Landed: identity-checked port ownership. #5015 (
52f2b490e0b57b1425354ffa1d5cd9fe49640e04) addedprobePortOwnerinsrc/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
findLiveProxyhad 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.lidge-jun commentedon Sep 18, 2026
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.
runtimeBaseUrlrefuses 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.tsandsystem-command.tsall go throughruntimeRequest, which resolves its base URL through that function. The opaquenot_foundand exit 4 are unreachable on that path.tests/cli/cli-transport-honesty.test.tspins both directions, including the one that matters for existing installs: a listener reporting no role is still used, so a proxy whose/healthzpredates 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
findLiveProxyhad simply resolved the client one.