diff --git a/src/integrations/state.ts b/src/integrations/state.ts index 0249987027..193281876d 100644 --- a/src/integrations/state.ts +++ b/src/integrations/state.ts @@ -437,10 +437,11 @@ export function exportContextOf(input: { * `http://::1:10100/v1` and `::` produced `http://:::10100/v1`, neither of * which is a URL, and a `0.0.0.0` bind wrote a wildcard address no client * can dial. `opencodeProxyBaseUrl` brackets IPv6 and maps wildcards to - * loopback, and every client we write into deserves the same answer the - * export command already gives. + * loopback. Managed integrations must stay on the public listener, though: + * the optional unauthenticated listener only exposes Codex's Responses + * routes, while these clients can use Chat Completions or Messages. */ - baseUrl: opencodeProxyBaseUrl(input.port, input.config.hostname, input.config), + baseUrl: opencodeProxyBaseUrl(input.port, input.config.hostname), models: input.models, config: input.config, }; diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 514fbc3220..eeed5b4058 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -12,7 +12,7 @@ import { homedir } from "node:os"; import { dirname } from "node:path"; import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export"; -import { shouldInjectApiAuthHeader } from "../codex/inject"; +import { isLoopbackHostname } from "../codex/inject"; import type { OcxConfig } from "../types"; import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io"; import { @@ -290,7 +290,7 @@ function applyOrRefreshIntegration( if (io.statKind(detectDir) !== "dir") { return refuse(clientId, "not_installed", "absent", `${clientId} is not installed`); } - if (isLoopbackOnly(clientId) && shouldInjectApiAuthHeader(input.config)) { + if (isLoopbackOnly(clientId) && !isLoopbackHostname(input.config.hostname)) { return refuse(clientId, "non_loopback", classified.state, `The generated ${clientId} integration is loopback-only and does not emit the admission header a non-loopback bind requires. Give it loopback access instead, through a tunnel or a local forwarder.`); } diff --git a/tests/clients/integrations-writer.test.ts b/tests/clients/integrations-writer.test.ts index de2f164710..65b77dadc5 100644 --- a/tests/clients/integrations-writer.test.ts +++ b/tests/clients/integrations-writer.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { buildClientContribution, type ExportModel } from "../../src/clients/config-export"; @@ -378,7 +378,7 @@ describe("apply", () => { expect(after.provider.opencodex!.models["mystery/model"]!.limit).toBeUndefined(); }); - test("ZCode on a hub writes and recognizes the unauthenticated loopback listener (#3306)", () => { + test("ZCode on a hub refuses the Responses-only unauthenticated listener", () => { const configPath = installZcode(); const request = input({ clientId: "zcode", @@ -391,16 +391,9 @@ describe("apply", () => { }); const result = applyIntegration(request); - expect(result.ok).toBe(true); - - const document = JSON.parse(readFileSync(configPath, "utf8")) as { - provider: Record; - }; - expect(document.provider.opencodex!.options).toMatchObject({ - apiKey: "opencodex-loopback", - baseURL: "http://127.0.0.1:10102/v1", - }); - expect(readIntegrationState(request)).toMatchObject({ state: "current" }); + expect(result).toMatchObject({ ok: false, reason: "non_loopback" }); + expect(existsSync(configPath)).toBe(false); + expect(exportContextOf(request).baseUrl).toBe("http://100.64.0.10:10100/v1"); }); test("ZCode key-order normalization stays refreshable with derived metadata (#2759)", () => {