Skip to content
Merged
179 changes: 179 additions & 0 deletions devlog/_plan/260911_hub_single_port/020_loopback_companion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# 020 — PR2: same-port loopback companion, honest hub-gate messages

Unit: `devlog/_plan/260911_hub_single_port`. Stack position 2 of 4 (PR1 = launchd repair,
PR3 = hub token UX, PR4 = docs/skill). Branch `codex/260911-l4-hub-loopback-companion`,
based on `dev` = `babb76449` (`Merge pull request #4240 … codex/260911-l4-client-catalog`); it
will be rebased onto PR1's branch. Issue: lidge-jun/opencodex#4236 (follow-ups 1 and 2,
defect 4).

The assigned worktree was created at `origin/main` (`06ec55363`), 1071 commits behind `dev`, so
the whole branch was rebased onto `dev` before verification. Conflicts were only in
`src/cli/index.ts` (three "startup left Codex native" call sites that `dev` had grown Raycast
comments around) and `src/codex/inject.ts` (the legacy-uncoordinated skip now lives inside
`applyLegacy`, under the config mutation lock); both were resolved by keeping `dev`'s structure
and putting the honest message inside it. Every count below is from the rebased tree.

## What shipped

### 1. `ocx gui` on a hub opens the local management ingress (first commit)

`ocx gui` derived its URL from the proxy bind, so a hub with `hostname: "<tailnet IP>"` opened a
browser at the tailnet origin while the hub's own loopback management ingress sat unused.
`selectDefaultGuiUrl` now prefers `http://localhost:<ingress port>` when `runtimeRole: "hub"` has
`hub.managementIngress.enabled`; every other topology keeps the previous derivation byte for byte.

### 2. The companion form of `unauthenticatedLoopbackListener`

`port` is now optional. `{ "enabled": true }` means "bind `127.0.0.1:<proxy port>`" — one port for
the whole hub: remote clients dial `hostname:port` with a credential, local processes dial
`127.0.0.1:port` without one, and every integration that hardcodes `http://127.0.0.1:<proxy port>`
(`ocx claude`, Claude Desktop, Cursor, `system-env`, the vision helper — the eight sites in the
issue's table) keeps working with **no edit to those call sites**. That was the whole point: the
PR changes where the socket is, not what the clients write.

Legal only when `hostname` is a specific non-loopback, non-wildcard address. On
`127.0.0.1`/`localhost`/`::1`/`0.0.0.0`/`::` the public listener already owns that loopback
address, so the pair is refused:

- at the write boundary (`loopbackListenerPortError` → `loopbackCompanionBindError`), reading both
`hostname` and the listener from the same candidate, so `ocx config set hostname 127.0.0.1` on a
companion host is refused by the same check rather than breaking the next start;
- at startup in `startServer`, before any bind, with the identical sentence — a hand edit that
skipped the boundary must not surface as EADDRINUSE from a rolled-back transaction.

The message names the collision (`127.0.0.1:<proxy port>`) and both fixes: set a distinct
`port`, or drop the listener because a loopback bind already admits local callers.

### 3. One resolver, every reader

`effectiveLoopbackListenerPort(config, publicPort)` in `src/codex/loopback-target.ts`, next to
`isLoopbackHostname`, plus `isWildcardHostname` / `loopbackCompanionAllowed`. Used by
`standaloneCodexRoutingTarget` (which `opencodeProxyBaseUrl`, `syncGrokConfig` and the integration
state exporter already go through), the server's listener transaction, and the `ocx status` drift
check. Deliberately NOT used in `chooseListenPort`: only an explicitly ported listener reserves a
port, because the companion form shares the public one and reserving it would refuse every start.

Startup log now distinguishes the two forms — companion:
`🔁 Loopback companion active on http://127.0.0.1:<port> — same port as the public listener; local
processes need no credential`; ported: today's four-line unauthenticated-surface warning, verbatim.

### 4. Drift warnings accept the whole locally reachable set (defect 4)

`grokFenceEndpointDrift` takes an optional second reachable port. `ocx status` passes
`effectiveLoopbackListenerPort(config, listen.port)`, so a fence naming the listener's port — the
port `ocx sync` itself wrote — is no longer reported as drift against a closed port. A third port
still warns, still against the public listener.

### 5. Hub-gate honesty (follow-up 2)

`localClientSyncAllowed` refusing to rewrite a hub's own clients is the right decision; reporting
it as the user's toggle was not. New `"hub-gated"` reason and one sentence:

> This machine is a hub; it does not rewrite its own Codex/Grok/Claude configs unless
> unauthenticatedLoopbackListener is enabled.

Threaded through `CodexWriteLockSkipReason` → `codexInjectLockOutcome` → `CodexInjectResult` →
`CodexSyncResult`, and used by `ocx sync`, `ocx sync-cache`, the three `startup left Codex native`
lines, and `ocx restore back` — which previously committed the toggle ON and then told the operator
to "retry after the competing integration change finishes", a writer that does not exist.

`ocx ensure` no longer strips the managed Grok block on a hub-gated skip: only an explicit
`clientIntegrations.grok === false` authorizes the strip. A gated hub with Grok ON is told why
nothing was written and `~/.grok/config.toml` is left exactly as it is.

## Decisions

- **The route allowlist was NOT widened.** `loopbackRouteAllowed` still serves only the Codex
data-plane set, so on a companion hub `http://127.0.0.1:<port>/v1/messages` and `/api/*` still
return 404. A companion is a bind-address change, never an admission change (maintainer review
on #4236: "Do not add `/api/*` to the unauthenticated listener"). A test pins this.
**Consequence, recorded as open work:** `ocx claude` and `fetchClaudeCodeState` on a
tailnet-bound hub reach a live socket but get 404 on the Anthropic wire and on
`/api/claude-code`. Closing that needs the two destination contracts the reviewer described —
authenticated local management discovery vs. per-wire inference — which is its own change, not
this one.
- **`port` stays non-OS-assigned in both forms.** An ephemeral port would change across restarts
while running app-servers held the previous `base_url` (#1102).
- **`startServer` stays synchronous** and the companion check is a plain throw before the first
`Bun.serve`, so the listener transaction and its rollback are untouched.
- **The hub-gate reason rides the existing lock skip channel** rather than a parallel one: the
skip is already linearized under the Codex write lock, and a second channel would let the
reason and the write disagree.
- Docs: the English `reference/configuration/server.md` paragraph that said "the port is required"
was false after this change, so it now documents both forms. The full docs/skill rewrite
(en + ko, remote-hub guide) is PR4; the other locale copies still describe only the ported form.

## Verification (exact commands, this branch)

```
bun run typecheck # clean
bun run privacy:scan # Privacy scan passed
bun test tests/server/loopback-listener-admission.test.ts \
tests/server/loopback-companion-client-targets.test.ts \
tests/server/server-loopback-host-gate.test.ts # 44 pass
bun test tests/server/loopback-listener-integration.test.ts # 34 pass
bun test tests/cli/hub-gated-local-clients.test.ts \
tests/cli/cli-dispatch.test.ts # 50 pass
bun test tests/providers/xai/grok-status.test.ts tests/providers/xai/grok-sync.test.ts \
tests/providers/xai/grok-lifecycle.test.ts \
tests/codex-integration/codex-desired-state.test.ts \
tests/codex-integration/codex-inject.test.ts \
tests/codex-integration/codex-sync-api.test.ts \
tests/cli/ensure-desired-integrations-race.test.ts # 149 pass
bun test tests/config/config-user-edits.test.ts tests/config/config-load-degrade.test.ts \
tests/cli/cli-json-contract.test.ts tests/cli/cli-restore-back.test.ts \
tests/clients/integrations-writer.test.ts tests/clients/sync-client-integrations.test.ts \
tests/server/startup-prompt.test.ts # 177 pass
bun test tests/cli/cli-transport-honesty.test.ts tests/cli/cli-status-json.test.ts \
tests/cli/cli-config-command.test.ts tests/cli/cli-start-journal-order.test.ts \
tests/cli/cli-capabilities.test.ts tests/cli/cli-help.test.ts # 108 pass
bun test tests/codex-integration/codex-write-lock.test.ts \
tests/codex-integration/codex-inject-write-lock.test.ts \
tests/codex-integration/codex-composed-acceptance.test.ts \
tests/codex-integration/codex-history-lock.test.ts \
tests/lab/lab-activation.test.ts tests/lab/core-lab-boundary.test.ts \
tests/test-layout.test.ts tests/test-layout-tooling.test.ts # 98 pass
bun test tests/update/update-stop-first.test.ts # 23 pass
```

One pre-existing test needed a harness line: `tests/clients/sync-client-integrations.test.ts`
transpiles the real `handleEnsure` body and evaluates it with every free identifier injected, so
the new `startupLeftCodexNativeLine` had to be added to that injection map.

The GUI does not render this field (`grep -rni loopback gui/src` finds only unrelated copy), so
there is no GUI change and `lint:gui` was not required.

No repository-wide suite (operator instruction); hosted CI at exact head is the proof.

New test files registered in `scripts/test-layout/layout.json` and
`tests/fixtures/test-layout-expected.json`:
`tests/server/loopback-companion-client-targets.test.ts`,
`tests/cli/hub-gated-local-clients.test.ts`.

The same-port integration case needs a non-loopback IPv4 interface to tell the two sockets apart;
on a host without one it warns and returns rather than passing silently (matching the existing
bind-scope case in that file).

## Left for the rest of the stack

- PR3: data-plane token auto-provisioning, `ocx hub invite`, the `ocx status` hub block (which
should print the companion state this PR introduces).
- PR4: `guides/remote-hub.md` en + ko around the one-port recipe, the other locale copies of
`reference/configuration/server.md`, `skills/ocx`.
- Follow-up (not in this stack as scoped): the Claude/management destination split described
above, i.e. what `ocx claude` should dial on a companion hub.

## Review round (coordinator)

Two findings from the read-only review, both fixed in a follow-up commit:

- `localClientSkipReason` claimed `"hub-gated"` even when the operator's own toggle was OFF, which
would have sent that operator to enable a listener that cannot make the sync happen. The reason is
now the conjunction the Grok path already used: toggle ON **and** gate closed. It takes the client
id (default `codex`) so a Grok OFF does not silence the Codex gate.
- `isWildcardHostname` missed the IPv6 unspecified aliases (`::0`, `[::0]`, `0::`,
`0:0:0:0:0:0:0:0`), bare `0`, and padded IPv4 zeros. A `hostname: "::0"` companion would have
passed both checks and then rolled back with EADDRINUSE — the exact misdiagnosis the check exists
to prevent. Normalisation now strips brackets and matches any all-zero spelling.

Verification: `bun test tests/cli/hub-gated-local-clients.test.ts tests/server/loopback-listener-admission.test.ts tests/server/loopback-listener-integration.test.ts tests/cli/cli-dispatch.test.ts tests/clients/sync-client-integrations.test.ts` → 144 pass / 0 fail; `bun run typecheck` clean.
21 changes: 20 additions & 1 deletion docs-site/src/content/docs/reference/configuration/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,28 @@ credential. The main listener is untouched — remote callers still need the tok
`ocx sync` then writes `base_url = "http://127.0.0.1:10200/v1"` into the managed Codex provider block
and omits the auth header, so a directly spawned app-server works without any credential plumbing.

The port is required and must differ from the proxy port. It is never OS-assigned: an ephemeral port
When you set `port`, it must differ from the proxy port. It is never OS-assigned: an ephemeral port
would change across restarts while already-running app-servers kept the previous `base_url`.

Omitting `port` selects the **companion** form — the listener binds the proxy port on `127.0.0.1`:

```json
{
"hostname": "100.76.170.81",
"port": 10100,
"unauthenticatedLoopbackListener": { "enabled": true }
}
```

Remote clients dial `100.76.170.81:10100` with a credential; local processes dial
`127.0.0.1:10100` without one. That is the address every local integration already writes, so
`ocx claude`, Claude Desktop, Cursor and the system-env injection keep working on a host whose
public bind they cannot reach. The companion form is accepted only when `hostname` is a specific
non-loopback, non-wildcard address: on `127.0.0.1`, `localhost` or `0.0.0.0` the public listener
already holds that loopback address, so OpenCodex refuses the pair at write time and at startup
rather than failing the second bind. On those binds you do not need the listener at all — a
loopback bind already admits local callers.

The listener serves only `POST /v1/responses`, its WebSocket upgrade, `POST /v1/responses/compact`,
`POST /v1/alpha/search` (the native Codex web-search relay), `GET /v1/models`, and the realtime
voice surface: the standalone WebSocket upgrades, WebRTC call creation (`POST /v1/live`,
Expand Down
2 changes: 2 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,7 @@
"history-migration-guardian.test.ts": "codex-integration",
"history-ocx-compaction-recovery.test.ts": "codex-integration",
"hyperbolic-provider.test.ts": "providers",
"hub-gated-local-clients.test.ts": "cli",
"identity-neutralize.test.ts": "adapters",
"init-backup-cleanup.test.ts": "service",
"init-eof.test.ts": "service",
Expand Down Expand Up @@ -806,6 +807,7 @@
"logs-timezone.test.ts": "server",
"loop-reasoning-replay.test.ts": "images",
"loop.test.ts": "images",
"loopback-companion-client-targets.test.ts": "server",
"loopback-listener-admission.test.ts": "server",
"loopback-listener-integration.test.ts": "server",
"macos-serial-lanes.test.ts": "ci-workflows",
Expand Down
55 changes: 46 additions & 9 deletions src/cli/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ import type { LivenessIo, LiveProxy } from "../server/proxy-liveness";
import type { OcxConfig } from "../types";
import type { OwnedIntegrationRefreshOutcome } from "../integrations/owned-refresh";
import { hasHelpFlag, printSubcommandUsage, printUsage } from "./help";
import { setIntegrationEnabled, shouldSyncCodexOnStart } from "../codex/desired-state";
import {
HUB_GATED_SKIP_MESSAGE,
localClientSkipMessage,
setIntegrationEnabled,
shouldSyncCodexOnStart,
} from "../codex/desired-state";
import { syncModelsToCodex } from "../codex/sync";
import { collectOrcaCodexHomeDiagnostic } from "../codex/home";
import { restoreNativeCodexAsync } from "../codex/inject";
Expand Down Expand Up @@ -54,6 +59,24 @@ export interface CliDispatchDeps {

type CommandRunner = (deps: CliDispatchDeps) => Promise<number>;

/**
* The hub's management ingress is deliberately loopback-only. Prefer it for
* a browser opened on the hub itself: the proxy listener may be restricted to
* a Tailscale address, while the ingress is the local authenticated dashboard.
*/
export function selectDefaultGuiUrl(
config: Pick<OcxConfig, "port" | "hostname" | "runtimeRole" | "hub">,
live: Pick<LiveProxy, "port" | "hostname"> | null,
probeHostname: (hostname: string | undefined) => string,
): string {
const ingress = config.runtimeRole === "hub" ? config.hub?.managementIngress : undefined;
if (ingress?.enabled) return `http://localhost:${ingress.port}`;

const guiHost = probeHostname(live?.hostname ?? config.hostname);
const hostname = guiHost === "127.0.0.1" ? "localhost" : guiHost;
return `http://${hostname}:${live?.port ?? config.port ?? 10100}`;
}

const commandRunners: Record<string, CommandRunner> = {
init: async () => {
const { runInit } = await import("./init");
Expand Down Expand Up @@ -105,7 +128,17 @@ const commandRunners: Record<string, CommandRunner> = {
}
const synced = await syncModelsToCodex(live.port);
if (synced.status === "skipped") {
return emitBack(false, "Codex integration is OFF; restore back did not change Codex. Retry after the competing integration change finishes.", 2);
// `setIntegrationEnabled` above just committed ON, so a skip here is NOT the toggle and
// is not a competing writer either — on a hub it is the role gate. Telling the operator
// to "retry after the competing integration change finishes" sent them waiting for a
// writer that does not exist (#4236).
return emitBack(
false,
synced.skippedReason === "hub-gated"
? `${HUB_GATED_SKIP_MESSAGE} restore back did not change Codex.`
: "Codex integration is OFF; restore back did not change Codex. Retry after the competing integration change finishes.",
2,
);
}
if (!synced.ok) {
return emitBack(false, "Plain `codex` was not switched back to opencodex. Fix the reported Codex config issue and retry.", 1);
Expand Down Expand Up @@ -372,7 +405,9 @@ const commandRunners: Record<string, CommandRunner> = {
);
let code = 0;
if (synced.status === "skipped") {
console.log("Codex integration is OFF; sync skipped and no Codex files changed.");
console.log(synced.skippedReason === "hub-gated"
? `${HUB_GATED_SKIP_MESSAGE} sync skipped and no Codex files changed.`
: "Codex integration is OFF; sync skipped and no Codex files changed.");
} else if (synced.status === "catalog-only") {
// Explicit sync with the integration OFF still refreshes the catalog/cache
// for side profiles that consume the proxy without injection.
Expand Down Expand Up @@ -448,7 +483,8 @@ const commandRunners: Record<string, CommandRunner> = {
const { readCodexCatalogPathForHome } = await import("../codex/catalog/parsing");
const { existsSync } = await import("node:fs");
const owningCodexHome = getCodexHome();
const desiredDisabled = !shouldSyncCodexOnStart(deps.loadConfig());
const cacheGateSnapshot = deps.loadConfig();
const desiredDisabled = !shouldSyncCodexOnStart(cacheGateSnapshot);
const invalidated = withCatalogWriteSerialization(owningCodexHome, permit =>
invalidateCodexModelsCacheWithPermit(permit, owningCodexHome, { allowWhenDesiredDisabled: true }));
const cacheJson = cacheArgs.includes("--json");
Expand All @@ -462,7 +498,11 @@ const commandRunners: Record<string, CommandRunner> = {
} else if (desiredDisabled && !cacheJson) {
// Worth saying in the human path, because it explains why nothing was written.
// Under --json this belongs on the envelope, not as a second stdout line.
console.log("Codex integration is OFF; no catalog or cache write resulted.");
console.log(localClientSkipMessage(
cacheGateSnapshot,
"Codex integration is OFF; no catalog or cache write resulted.",
"No catalog or cache write resulted.",
));
}
// `completed` with a falsy value means the cache was NOT rewritten. Previously every
// outcome exited 0, so a script could not tell a refreshed cache from a skipped one.
Expand Down Expand Up @@ -535,10 +575,7 @@ const commandRunners: Record<string, CommandRunner> = {
return 1;
}
}
// Open the host the proxy actually binds — `localhost` only answers for
// loopback/wildcard binds, not a concrete LAN/IPv6 hostname.
const guiHost = deps.probeHostname(live?.hostname ?? config.hostname);
const guiUrl = `http://${guiHost === "127.0.0.1" ? "localhost" : guiHost}:${live?.port ?? config.port}`;
const guiUrl = selectDefaultGuiUrl(config, live, deps.probeHostname);
console.log(`Opening ${guiUrl}`);
const { openUrl } = await import("../lib/open-url");
openUrl(guiUrl);
Expand Down
Loading
Loading