From c3a01990dae7e18e73dc6dcf31577439be77d538 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Sun, 4 Oct 2026 20:29:15 +0000 Subject: [PATCH 1/3] fix(management): keep inference ports independent of management ingress --- .../src/content/docs/guides/remote-hub.md | 4 + scripts/test-layout/layout.json | 1 + src/server/index/serve-options.ts | 8 +- .../management/agent-settings-routes.ts | 8 +- src/server/management/context.ts | 9 +- src/server/management/integration-routes.ts | 10 +-- src/server/management/model-routes.ts | 6 +- .../management/native-integration-routes.ts | 4 +- .../ADR-6598-management-inference-port.md | 9 ++ structure/gui-and-management-api.md | 4 + tests/fixtures/test-layout-expected.json | 1 + .../management-client-config-route.test.ts | 18 +++- .../server/management-inference-port.test.ts | 85 +++++++++++++++++++ 13 files changed, 147 insertions(+), 20 deletions(-) create mode 100644 structure/decisions/ADR-6598-management-inference-port.md create mode 100644 tests/server/management-inference-port.test.ts diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 775ceeb1cc2..952f19da575 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -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:` 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, diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 61c524498e8..1f0d2d4ccaa 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -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", diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index e62d352f667..b8a51effc6f 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -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 diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index e5ba775d68e..ebe7ca0173c 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -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 ?? []); @@ -898,7 +898,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); return jsonResponse({ ...state, port: runtimePort }); } catch (error) { return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); @@ -966,7 +966,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); @@ -1131,7 +1131,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, diff --git a/src/server/management/context.ts b/src/server/management/context.ts index 9162b855034..37e7c8e8bd9 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -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; } @@ -191,3 +191,10 @@ export interface ManagementContext { convergeCodexCatalog: () => Promise; syncClaudeAgentDefsBestEffort: () => Promise; } + +/** A management-only ingress cannot supply the port used by generated inference clients. */ +export function managementInferencePort(ctx: Pick): 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; +} diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index b87a4343713..2eece64a5f9 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -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, @@ -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(), @@ -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, @@ -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, @@ -619,7 +619,7 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise loadExportModels(config), }); return { catalogRefresh, clientIntegrations }; @@ -562,7 +562,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise { * 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; /* diff --git a/structure/decisions/ADR-6598-management-inference-port.md b/structure/decisions/ADR-6598-management-inference-port.md new file mode 100644 index 00000000000..90e57987965 --- /dev/null +++ b/structure/decisions/ADR-6598-management-inference-port.md @@ -0,0 +1,9 @@ +# 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. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index feaf481ff8f..08a9abce8de 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -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`, never the request URL port. `src/server/index/serve-options.ts` supplies the public runtime-bound port through `liveListenPort`; direct route fixtures fall back to config. The existing inference resolver still selects an explicit companion port. Request identity, management authentication and ingress restrictions are unchanged. + +> 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 diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e50b3203b50..5e648d1b623 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -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", diff --git a/tests/server/management-client-config-route.test.ts b/tests/server/management-client-config-route.test.ts index d6513ddc437..d4e0c5adbfc 100644 --- a/tests/server/management-client-config-route.test.ts +++ b/tests/server/management-client-config-route.test.ts @@ -114,8 +114,8 @@ function baseConfig(overrides: Partial = {}): OcxConfig { } as OcxConfig; } -async function clientConfigApi(config: OcxConfig, query: string): Promise { - const url = new URL(`http://127.0.0.1:10100/api/client-config${query}`); +async function clientConfigApi(config: OcxConfig, query: string, port = 10100): Promise { + const url = new URL(`http://127.0.0.1:${port}/api/client-config${query}`); const response = await handleManagementAPI( new Request(url, { headers: { Host: url.host } }), url, @@ -137,6 +137,20 @@ async function modelRows(config: OcxConfig): Promise { return await response!.json() as ModelRow[]; } +test.each(["opencode", "pi"])("management ingress must not become %s inference destination (#6598)", async client => { + const config = baseConfig({ + runtimeRole: "hub", + hostname: "100.64.0.2", + unauthenticatedLoopbackListener: { enabled: true }, + hub: { managementIngress: { enabled: true, port: 10101 } }, + } as Partial); + const response = await clientConfigApi(config, `?client=${client}`, 10101); + expect(response.status).toBe(200); + const body = await response.json() as ClientConfigEnvelope; + expect(body.text).toContain("http://127.0.0.1:10100/v1"); + expect(body.text).not.toContain("http://127.0.0.1:10101/v1"); +}); + describe("native Anthropic image input reaches client documents", () => { test.each(["anthropic", "anthropic-apikey"])("all capability-aware exports advertise image input for %s", async (provider) => { const config = { diff --git a/tests/server/management-inference-port.test.ts b/tests/server/management-inference-port.test.ts new file mode 100644 index 00000000000..b1c457b8d64 --- /dev/null +++ b/tests/server/management-inference-port.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, readFileSync } from "node:fs"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { managementInferencePort } from "../../src/server/management/context"; +import { setIntegrationMutationFlightTestHooks, setIntegrationPathTestHooks } from "../../src/server/management/integration-routes"; +import { resetExportSnapshotForTests } from "../../src/server/management/model-rows"; +import type { OcxConfig } from "../../src/types"; +import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +let home: TempHome; +const originalFetch = globalThis.fetch; +beforeEach(() => { + home = createTempHome("ocx-management-inference-port-"); + globalThis.fetch = (async () => { throw new Error("Network forbidden"); }) as typeof fetch; + setIntegrationPathTestHooks({ env: {}, home: home.root }); + setIntegrationMutationFlightTestHooks({ store: createIntegrationStateStore(home.path("integrations")) }); + resetExportSnapshotForTests(); +}); +afterEach(() => { + setIntegrationMutationFlightTestHooks(null); + setIntegrationPathTestHooks(null); + resetExportSnapshotForTests(); + globalThis.fetch = originalFetch; + home.remove(); +}); +function config(): OcxConfig { + return { + port: 10100, hostname: "100.64.0.2", runtimeRole: "hub", + hub: { managementIngress: { enabled: true, port: 10101 } }, + unauthenticatedLoopbackListener: { enabled: true }, + defaultProvider: "fixture", apiKeys: [], + providers: { fixture: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", liveModels: false, models: ["m"] } }, + } as unknown as OcxConfig; +} +async function route(cfg: OcxConfig, path: string, ingress: number, livePort?: number, init: RequestInit = {}) { + const url = new URL(`http://127.0.0.1:${ingress}${path}`); + const response = await handleManagementAPI(new Request(url, { ...init, headers: { Host: url.host, ...init.headers } }), url, cfg, { + saveConfigPreservingClaudeCode: () => {}, + createManagementConvergeCodex: catalogConvergenceFactory(), + ...(livePort === undefined ? {} : { liveListenPort: () => livePort }), + }); + expect(response).not.toBeNull(); + return response!; +} + +test.each([10100, 10101])("OpenCode export uses runtime port, not ingress %s", async ingress => { + const response = await route(config(), "/api/client-config?client=opencode", ingress, 23456); + expect(response.status).toBe(200); + const body = await response.json() as { config: { provider: { opencodex: { options: { baseURL: string } } } } }; + expect(body.config.provider.opencodex.options.baseURL).toBe("http://127.0.0.1:23456/v1"); +}); +test("an explicit loopback inference port stays distinct from both runtime and management ports", async () => { + const cfg = config(); + cfg.unauthenticatedLoopbackListener = { enabled: true, port: 10500 }; + const response = await route(cfg, "/api/client-config?client=pi", 10101, 23456); + expect(response.status).toBe(200); + const body = await response.json() as { config: { providers: { opencodex: { baseUrl: string } } } }; + expect(body.config.providers.opencodex.baseUrl).toBe("http://127.0.0.1:10500/v1"); +}); +test("ephemeral configuration uses the runtime-bound port", async () => { + const cfg = config(); + cfg.port = 0; + expect(managementInferencePort({ config: cfg, deps: { liveListenPort: () => 23456 } })).toBe(23456); +}); +test("direct route fixtures without a lifecycle port use config, not their management URL", async () => { + expect(managementInferencePort({ config: config(), deps: {} })).toBe(10100); + const response = await route(config(), "/api/client-config?client=opencode", 10101); + const body = await response.json() as { config: { provider: { opencodex: { options: { baseURL: string } } } } }; + expect(body.config.provider.opencodex.options.baseURL).toBe("http://127.0.0.1:10100/v1"); +}); +test.each(["opencode", "pi"] as const)("%s apply writes runtime inference URL through the actual writer", async client => { + const spec = INTEGRATION_CLIENTS[client]; + mkdirSync(spec.detectDir({}, home.root), { recursive: true }); + const response = await route(config(), `/api/client-integrations/${client}`, 10101, 23456, { + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled: true }), + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ ok: true }); + const written = readFileSync(spec.configPath({}, home.root), "utf8"); + expect(written).toContain("http://127.0.0.1:23456/v1"); + expect(written).not.toContain("http://127.0.0.1:10101/v1"); +}); From 60ccb2948ed503f19075f1623a64c11ff0d5fe13 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 5 Oct 2026 10:53:07 +0900 Subject: [PATCH 2/3] fix(management): keep Cursor gateway on the bound inference port; cover ingress export Cursor status now prefers the lifecycle-bound public port, then the PID-matched runtime record, then config, so a management-only ingress port can never become the advertised gateway. Adds a real-server regression that exports a client config through hub management ingress and asserts the public bound port, plus Cursor precedence cases, and records resolver ownership and known limitations in ADR-6598. --- .../management/cursor-integration-routes.ts | 6 +-- .../ADR-6598-management-inference-port.md | 2 + structure/gui-and-management-api.md | 2 +- .../cursor/cursor-integration-status.test.ts | 27 +++++++++++- .../loopback-listener-integration.test.ts | 41 +++++++++++++++++++ 5 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/server/management/cursor-integration-routes.ts b/src/server/management/cursor-integration-routes.ts index 1a5165ecee9..ff694c4d6f9 100644 --- a/src/server/management/cursor-integration-routes.ts +++ b/src/server/management/cursor-integration-routes.ts @@ -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 diff --git a/structure/decisions/ADR-6598-management-inference-port.md b/structure/decisions/ADR-6598-management-inference-port.md index 90e57987965..af1f0ce4fda 100644 --- a/structure/decisions/ADR-6598-management-inference-port.md +++ b/structure/decisions/ADR-6598-management-inference-port.md @@ -7,3 +7,5 @@ - 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. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 4da040bcfa0..333ab103d98 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -74,7 +74,7 @@ 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`, never the request URL port. `src/server/index/serve-options.ts` supplies the public runtime-bound port through `liveListenPort`; direct route fixtures fall back to config. The existing inference resolver still selects an explicit companion port. Request identity, management authentication and ingress restrictions are unchanged. +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) diff --git a/tests/providers/cursor/cursor-integration-status.test.ts b/tests/providers/cursor/cursor-integration-status.test.ts index ee9afb7e94d..09600ef53c2 100644 --- a/tests/providers/cursor/cursor-integration-status.test.ts +++ b/tests/providers/cursor/cursor-integration-status.test.ts @@ -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() } }); @@ -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); diff --git a/tests/server/loopback-listener-integration.test.ts b/tests/server/loopback-listener-integration.test.ts index 150c1da8cbe..062abbab23c 100644 --- a/tests/server/loopback-listener-integration.test.ts +++ b/tests/server/loopback-listener-integration.test.ts @@ -157,6 +157,47 @@ describe("hub management ingress", () => { } }, SERVER_BUDGET_MS); + test("client export through management ingress names the public bound port and ingress refuses inference", async () => { + const managementPort = await freePort(); + const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); + saveConfig({ + ...hubIngressConfig(managementPort), + port: 0, + unauthenticatedLoopbackListener: { enabled: false }, + claudeCode: { intercept: { enabled: false } }, + defaultProvider: "static", + providers: { + static: { + adapter: "openai-chat", + baseUrl: "https://provider.example.test/v1", + liveModels: false, + models: ["static-model"], + }, + }, + }); + const server = startServer(publicPort); + const base = `http://127.0.0.1:${managementPort}`; + const headers = { + Host: "hub.example.test", + Origin: "https://hub.example.test", + "x-opencodex-api-key": "admin-secret", + }; + try { + expect(server.port).not.toBe(managementPort); + const exported = await fetch(`${base}/api/client-config?client=opencode`, { headers }); + expect(exported.status).toBe(200); + const body = await exported.json() as { + config: { provider: { opencodex: { options: { baseURL: string } } } }; + }; + expect(body.config.provider.opencodex.options.baseURL).toBe(`http://127.0.0.1:${server.port}/v1`); + expect(body.config.provider.opencodex.options.baseURL).not.toContain(`:${managementPort}/`); + const inference = await fetch(`${base}/v1/models`, { headers }); + expect(inference.status).toBe(404); + } finally { + await server.stop(true); + } + }, SERVER_BUDGET_MS); + test("default-denies every data, health, readiness, WebSocket, and unknown-static route", async () => { const managementPort = await freePort(); const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); From 6e454f57085d43dac6ae39807954811c43c6527e Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 5 Oct 2026 12:30:57 +0900 Subject: [PATCH 3/3] fix(management): use the live bound port for Desktop provider-change auto-apply autoApplyDesktopBestEffort wrote the Desktop 3P config from config.port, so a CLI override or ephemeral bind could leave Desktop pointing at a port nothing serves. It now uses managementInferencePort like the other management writers; the roster-update fixture asserts the live port reaches the writer. --- src/server/management/agent-settings-routes.ts | 3 ++- .../claude-desktop-first-party-guards.test.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index ebe7ca0173c..8ac6c34f171 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -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, diff --git a/tests/claude-integration/claude-desktop-first-party-guards.test.ts b/tests/claude-integration/claude-desktop-first-party-guards.test.ts index a0b97d9d18b..4f89d8baf41 100644 --- a/tests/claude-integration/claude-desktop-first-party-guards.test.ts +++ b/tests/claude-integration/claude-desktop-first-party-guards.test.ts @@ -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" }; @@ -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); }); }