Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs-site/src/content/docs/guides/remote-hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ public-internet surface and is outside this deployment model.

With management ingress enabled, the local dashboard command opens `http://127.0.0.1:<management port>` so the address matches the IPv4-only listener without resolving `localhost`.

Client configurations exported or applied through that dashboard still target the inference
listener, not the management port. Local clients use the configured loopback companion port
(or the actual public listener port when the companion has no explicit port).

## Trust and consent boundaries

- Provider and OAuth credentials stay on the hub. Never copy them into a client, image layer,
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1160,6 +1160,7 @@
"management-anthropic-reset-grants.test.ts": "server",
"management-api-logs-metrics.test.ts": "server",
"management-client-config-route.test.ts": "server",
"management-inference-port.test.ts": "server",
"management-google-tool-schema-policy.test.ts": "server",
"management-integration-journal-delete.test.ts": "server",
"management-integration-routes.test.ts": "server",
Expand Down
8 changes: 5 additions & 3 deletions src/server/index/serve-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,9 +299,11 @@ export function createServeOptions(ctx: ServeOptionsContext) {
const requestMetrics = metricsExportEnabled(config)
? createRequestMetricsOwner(Date.now() / 1000, cachedKiroQuotaMetricRows) : undefined;
const requestMetricsLogContext = requestMetrics ? { requestMetricsRecorder: requestMetrics } : {};
const requestManagementApiDeps: ManagementApiDeps = requestMetrics
? { ...managementApiDeps, requestMetrics: { snapshot: () => requestMetrics.snapshot() } }
: managementApiDeps;
const requestManagementApiDeps: ManagementApiDeps = {
...managementApiDeps,
liveListenPort: () => ctx.boundPort ?? listenPort,
...(requestMetrics ? { requestMetrics: { snapshot: () => requestMetrics.snapshot() } } : {}),
};
const serveOptions = {
idleTimeout: 255,
// Bun rejects an oversized body before `fetch` runs, so the listener has to be raised
Expand Down
11 changes: 6 additions & 5 deletions src/server/management/agent-settings-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export function setGrokApplyFlightTestHooks(
grokApplyFlight = null;
grokApplyHighWaterBytes = 0;
}
import type { ManagementContext } from "./context";
import { managementInferencePort, type ManagementContext } from "./context";

async function injectionModelOptions(config: OcxConfig, models: readonly CatalogModel[]) {
const disabled = new Set(config.disabledModels ?? []);
Expand Down Expand Up @@ -262,7 +262,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
const writtenProfile = current.claudeCode.desktopProfile;
const markerBaseline = captureDesktopAppliedMarker(writtenProfile);
const result = (deps.writeDesktop3pConfig ?? writeDesktop3pConfig)(
current.port ?? 10100,
// Live bound port, not config: a CLI override or ephemeral bind must not strand Desktop (#6598).
managementInferencePort({ config: current, deps }) ?? 10100,
[...desktopVisibleNativeSlugs(current)],
routed,
current.apiKeys?.[0]?.key,
Expand Down Expand Up @@ -898,7 +899,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
if (url.pathname === "/api/claude-desktop" && req.method === "GET") {
try {
const state = await buildClaudeDesktopState(config);
const runtimePort = Number(url.port) || config.port;
const runtimePort = managementInferencePort(ctx);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return jsonResponse({ ...state, port: runtimePort });
} catch (error) {
return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
Expand Down Expand Up @@ -966,7 +967,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
};
}
const saved = await buildClaudeDesktopState(config);
const runtimePort = Number(url.port) || config.port;
const runtimePort = managementInferencePort(ctx);
return jsonResponse({ ok: true, ...saved, port: runtimePort });
} catch (error) {
return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
Expand Down Expand Up @@ -1131,7 +1132,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
// Picker mode belongs to first-party: stop terminating claude.ai before the gateway lands.
const pickerOff = await ops.disableLocked({ persist: false });
const result = (deps.writeDesktop3pConfig ?? writeDesktop3pConfig)(
Number(url.port) || latest.port,
managementInferencePort({ config: latest, deps }),
[...desktopVisibleNativeSlugs(latest)],
routed,
latest.apiKeys?.[0]?.key,
Expand Down
9 changes: 8 additions & 1 deletion src/server/management/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export interface ManagementApiDeps {
issueApiKey?: (config: OcxConfig, name: string) => IssuedApiKey;
revokeApiKey?: (config: OcxConfig, id: string) => boolean;
loadLinkCandidates?: () => Array<{ alias: string; source: "ssh_config" | "tailscale" }>;
/** The port this runtime listens on; a join is refused unless it is the configured port. */
/** Bound public inference port, not the ingress receiving this management request. */
liveListenPort?: () => number | undefined;
now?: () => number;
}
Expand Down Expand Up @@ -191,3 +191,10 @@ export interface ManagementContext {
convergeCodexCatalog: () => Promise<CatalogDisposition>;
syncClaudeAgentDefsBestEffort: () => Promise<void>;
}

/** A management-only ingress cannot supply the port used by generated inference clients. */
export function managementInferencePort(ctx: Pick<ManagementContext, "config" | "deps">): number {
// Lifecycle owns the actual port (including CLI overrides/ephemeral binds); direct route
// fixtures fall back to config without consulting another runtime's state file.
return ctx.deps.liveListenPort?.() ?? ctx.config.port;
}
6 changes: 3 additions & 3 deletions src/server/management/cursor-integration-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ export async function buildCursorIntegrationStatus(
const privateInference = pick(installs, "private-inference");
const regular = pick(installs, "regular");
const runtime = (deps.readRuntimePort ?? readRuntimePort)(process.pid);
// The port the browser reached is the one Cursor on the same machine will reach too; the
// runtime record and config.port are fallbacks for a request that carries no port.
const port = runtime?.port ?? (Number(ctx.url?.port) || config.port);
// The lifecycle-bound public port wins, then the PID-matched runtime record, then config.
// A management-only ingress port must never become the Cursor gateway (#6598).
const port = deps.liveListenPort?.() ?? runtime?.port ?? config.port;
// Cursor runs on this machine, so the gateway URL it is told to paste is the LOCAL one: the
// unauthenticated loopback listener when one is enabled, and otherwise the bind address on the
// public port — 127.0.0.1 for a loopback or wildcard bind exactly as before, and the tailnet
Expand Down
10 changes: 5 additions & 5 deletions src/server/management/integration-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import {
} from "../../integrations/mutation-flight";
import { jsonResponse } from "../auth-cors";
import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
import type { ManagementContext } from "./context";
import { managementInferencePort, type ManagementContext } from "./context";
import { exportSnapshotIdentity, loadExportModels, previewExportSnapshot } from "./model-rows";
import {
previewIntegration,
Expand Down Expand Up @@ -230,7 +230,7 @@ function asideOptions(ctx: ManagementContext): AsideProfileRouteOptions {
return {
input: () => input ??= {
config: ctx.config,
port: Number(ctx.url.port) || ctx.config.port,
port: managementInferencePort(ctx),
models: () => loadExportModels(ctx.config),
store: integrationStore(),
...pathOverrides(),
Expand All @@ -252,7 +252,7 @@ async function buildIntegrationWriteInput(
clientId,
models: await loadExportModels(ctx.config),
config: ctx.config,
port: Number(ctx.url.port) || ctx.config.port,
port: managementInferencePort(ctx),
...(droidReasoningDefaults === undefined ? {} : { droidReasoningDefaults: { ...droidReasoningDefaults } }),
store,
io: integrationMutationTestHooks?.io,
Expand Down Expand Up @@ -284,7 +284,7 @@ async function buildIntegrationPreviewInput(
clientId,
models: snapshot.models,
config: ctx.config,
port: Number(ctx.url.port) || ctx.config.port,
port: managementInferencePort(ctx),
...(droidReasoningDefaults === undefined ? {} : { droidReasoningDefaults: { ...droidReasoningDefaults } }),
store,
io: integrationMutationTestHooks?.io,
Expand Down Expand Up @@ -619,7 +619,7 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
if (url.pathname === "/api/client-integrations" && req.method === "GET") {
try {
const models = await loadExportModels(ctx.config);
const port = Number(url.port) || ctx.config.port;
const port = managementInferencePort(ctx);
// One store for the whole collection read: without it this route retried
// maintenance and counted snapshots in the default store even when the
// caller had bound everything else to a temp root (A-gate round 13).
Expand Down
6 changes: 3 additions & 3 deletions src/server/management/model-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ import type {

import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
import type { ManagementContext } from "./context";
import { managementInferencePort, type ManagementContext } from "./context";
import { effectiveModelReasoningEfforts, inheritedModelReasoningEfforts, listManagementModelRows, loadExportModels } from "./model-rows";
import { initialModelSelectionPending } from "../../providers/initial-model-selection";
import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
Expand Down Expand Up @@ -204,7 +204,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
?? (await import("../../integrations/catalog-refresh")).refreshOwnedCatalogIntegrations;
const clientIntegrations = await refresh({
config,
port: Number(url.port) || config.port,
port: managementInferencePort(ctx),
models: () => loadExportModels(config),
});
return { catalogRefresh, clientIntegrations };
Expand Down Expand Up @@ -562,7 +562,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
reason: "non_loopback",
}, 400, req, config);
}
const baseUrl = opencodeProxyBaseUrl(Number(url.port) || config.port, config.hostname, config);
const baseUrl = opencodeProxyBaseUrl(managementInferencePort(ctx), config.hostname, config);
let models: ExportModel[];
try {
// The ONE loader every export surface uses. It carries the visibility
Expand Down
4 changes: 2 additions & 2 deletions src/server/management/native-integration-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import type { CodexNativeRestoreResult } from "../../codex/inject";
import type { OcxConfig } from "../../types";
import { jsonResponse } from "../auth-cors";
import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
import type { ManagementContext } from "./context";
import { managementInferencePort, type ManagementContext } from "./context";

export type NativeIntegrationClientId = "claude" | "grok" | "codex" | "claude-desktop";

Expand Down Expand Up @@ -540,7 +540,7 @@ async function handleGrokToggle(ctx: ManagementContext): Promise<Response> {
* a stale config.hostname picks the wrong loopback policy branch entirely.
*/
const runtime = (deps.readRuntimePort ?? readRuntimePort)(process.pid);
const port = runtime?.port ?? (Number(ctx.url.port) || config.port);
const port = deps.liveListenPort?.() ?? runtime?.port ?? managementInferencePort(ctx);
const hostname = runtime?.hostname ?? config.hostname;

/*
Expand Down
11 changes: 11 additions & 0 deletions structure/decisions/ADR-6598-management-inference-port.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Decision recorded under "Dashboard serving"

[Decision Log]
- Purpose and intent: keep management-only ingress ports out of generated inference clients (#6598).
- Prior implementation and constraints: routes used the request URL port as the public data-plane port; a hub dashboard on 10101 consequently exported 10101/v1 even though that listener rejects inference. Config alone can be stale or contain an ephemeral port.
- Alternatives considered: substitute config.port at each site; read runtime-port.json on every request; rewrite the request URL; pass the lifecycle-owned public port.
- Selected approach: supply liveListenPort from the shared server composition and resolve it in managementInferencePort, with config.port only for direct-dispatch fixtures.
- Why: lifecycle already knows the actual public bind and CLI override. This keeps request identity/authentication intact, avoids additional private-state I/O, and gives every affected route one rule.
- Consequences: standalone and hub management requests select the same inference endpoint. Explicit companion ports retain precedence through the existing inference resolver. Direct-dispatch tests requiring a bound override must inject it. Required independent boundary review remains separate from correctness fixtures.
- Resolution boundary: `managementInferencePort` yields the PUBLIC bound port, not a final client destination. Explicit loopback companion precedence stays in `standaloneCodexRoutingTarget` (`src/codex/inject/routing-target.ts`), `effectiveLoopbackListenerPort` (`src/codex/loopback-target.ts`) and `localInferenceDestination` (`src/lib/local-destinations.ts`). Cursor status in `src/server/management/cursor-integration-routes.ts` uses live listener -> PID-matched runtime record -> config, then `localInferenceDestination`; it never selects the request URL port.
- Known limitations: the Grok toggle does not resolve a loopback companion on a tailnet hub. `/api/keys` endpoint metadata on wildcard binds still derives host:port from Origin/URL/Host. The Codex toggle and Claude Desktop enable paths in `src/server/management/native-integration-routes.ts` use runtime -> config without the live port. A port-less companion with requested port 0 can receive a separately allocated ephemeral port. These paths are outside this port-selection fix.
4 changes: 4 additions & 0 deletions structure/gui-and-management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ the web route. The native panel introduces no management endpoint or credential

## Dashboard serving

Management exports, integration write/preview/refresh inputs and Claude port projections use `managementInferencePort` in `src/server/management/context.ts` for the PUBLIC bound port, never the request URL port. `src/server/index/serve-options.ts` supplies `liveListenPort`; direct fixtures fall back to config. Cursor status in `src/server/management/cursor-integration-routes.ts` uses live -> PID-matched runtime -> config. Destination resolution and explicit loopback companion precedence stay in `standaloneCodexRoutingTarget` (`src/codex/inject/routing-target.ts`), `effectiveLoopbackListenerPort` (`src/codex/loopback-target.ts`) and `localInferenceDestination` (`src/lib/local-destinations.ts`).

> Decision record: [ADR-6598](decisions/ADR-6598-management-inference-port.md)

The Factory Droid integration page edits the
[owned model defaults](clients/integrations.md#droid-reasoning-defaults). Its draft
is included in the existing preview and confirmation request, with editing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ for (const switchDuringDiscovery of [false, true]) {
const writes: unknown[] = [];
const request = dispatch("/api/subagent-models", { method: "PUT", body: JSON.stringify({ models: ["mock/keep"] }) }, live, {
fetchAllModels: discovery.fetchAllModels,
liveListenPort: () => 23456,
writeDesktop3pConfig: (...args) => {
writes.push(args);
return { written: true, path: "fixture", fingerprint: "fedcba9876543210" };
Expand All @@ -232,6 +233,8 @@ for (const switchDuringDiscovery of [false, true]) {
const reply = await request;
expect(reply.status).toBe(200);
expect(writes.length).toBe(switchDuringDiscovery ? 0 : 1);
// The live bound port wins over config.port (10100), as for every other management writer (#6598).
if (!switchDuringDiscovery) expect((writes[0] as unknown[])[0]).toBe(23456);
});
}

Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,7 @@
"main-quota-provenance.test.ts": "codex-integration", "main-quota-window-observation.test.ts": "codex-integration",
"management-anthropic-reset-grants.test.ts": "server", "management-api-logs-metrics.test.ts": "server",
"management-client-config-route.test.ts": "server", "management-google-tool-schema-policy.test.ts": "server",
"management-inference-port.test.ts": "server",
"management-integration-journal-delete.test.ts": "server", "management-integration-routes.test.ts": "server",
"management-metrics-export.test.ts": "server", "management-model-roster-gather-race.test.ts": "server",
"management-model-roster.test.ts": "server", "model-export-new-model-policy.test.ts": "server",
Expand Down
27 changes: 26 additions & 1 deletion tests/providers/cursor/cursor-integration-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,30 @@ describe("GET /api/native-integrations/cursor", () => {
});
});

test("gateway port prefers the live listener, then runtime, then config, never management ingress", async () => {
const config: OcxConfig = { ...statusConfig(), port: 10100, hostname: "127.0.0.1" };
const url = new URL("http://127.0.0.1:10101/api/native-integrations/cursor");
const runtime = { pid: process.pid, port: 34567 };
const cases = [
{ livePort: 23456, runtime, expected: "http://127.0.0.1:23456/v1" },
{ livePort: undefined, runtime, expected: "http://127.0.0.1:34567/v1" },
{ livePort: undefined, runtime: null, expected: "http://127.0.0.1:10100/v1" },
];
for (const { livePort, runtime, expected } of cases) {
const status = await buildCursorIntegrationStatus({
config,
url,
deps: {
liveListenPort: () => livePort,
readRuntimePort: () => runtime,
loadCursorEffortTable: () => null,
},
}, []);
expect(status.gateway.baseUrl).toBe(expected);
expect(status.gateway.baseUrl).not.toContain(":10101/");
}
});

test("reports bundle effort-table provenance and unmatched model families through the server deps seam", async () => {
saveConfig(statusConfig());
const server = startServer(0, { managementApi: { loadCursorEffortTable: () => fixtureEffortTable() } });
Expand Down Expand Up @@ -325,9 +349,10 @@ describe("GET /api/native-integrations/cursor", () => {
}) as typeof fetch;
try {
const status = await buildCursorIntegrationStatus(
{ config: statusConfig(), deps: { readRuntimePort: () => undefined, loadCursorEffortTable: () => null }, url: new URL("http://127.0.0.1:10100/api/native-integrations/cursor") },
{ config: statusConfig(), deps: { liveListenPort: () => 10100, readRuntimePort: () => undefined, loadCursorEffortTable: () => null }, url: new URL("http://127.0.0.1:10100/api/native-integrations/cursor") },
[{ build: "regular", path: "/opt/cursor", version: null }],
);
expect(status.gateway.baseUrl).toBe("http://127.0.0.1:10100/v1");
expect(status.regularCursor.installed).toBe(true);
expect(status.privateInference.installed).toBe(false);
expect("localInstaller" in status).toBe(false);
Expand Down
Loading
Loading