Skip to content
7 changes: 4 additions & 3 deletions src/integrations/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the new managed-integration refusal

When hostname is non-loopback and unauthenticatedLoopbackListener is enabled, this change moves managed exports from the secondary listener to the authenticated public listener, causing every loopbackOnly client—including Pi, OMP, MCode, ZCode, Aside, and Raycast—to be refused instead of connected or refreshed. This is a user-visible compatibility change, but the Integrations documentation still does not explain that the secondary listener is Codex-only and cannot enable these managed clients on a hub; update the English guide and ensure translated pages do not contradict it.

AGENTS.md reference: src/AGENTS.md:L29-L29

Useful? React with 👍 / 👎.

models: input.models,
config: input.config,
};
Expand Down
4 changes: 2 additions & 2 deletions src/integrations/writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.`);
}
Expand Down
17 changes: 5 additions & 12 deletions tests/clients/integrations-writer.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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",
Expand All @@ -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<string, { options: { apiKey: string; baseURL: string } }>;
};
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)", () => {
Expand Down
Loading