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
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ Le port est obligatoire et doit différer du port proxy. Il n'est jamais attribu
changerait au fil des redémarrages tandis que les serveurs d'applications déjà en cours d'exécution conservaient le `base_url` précédent.

L'écouteur ne sert que `POST /v1/responses`, sa mise à niveau WebSocket, `POST /v1/responses/compact`,
et `GET /v1/models`. Tout le reste, y compris `/api/*` et le tableau de bord, renvoie `404`.
`POST /v1/alpha/search` (le relais de recherche web natif de Codex), `GET /v1/models` et les mises à
niveau WebSocket vocales autonomes. Tout le reste, y compris `/api/*` et le tableau de bord, renvoie `404`.

:::danger[Surface non authentifiée]
Chaque processus de la machine peut utiliser cet écouteur. Il consomme le quota du compte et utilise les identifiants de
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,9 @@ The port is required and must differ from the proxy port. It is never OS-assigne
would change across restarts while already-running app-servers kept the previous `base_url`.

The listener serves only `POST /v1/responses`, its WebSocket upgrade, `POST /v1/responses/compact`,
and `GET /v1/models`. Everything else, including `/api/*` and the dashboard, returns `404`.
`POST /v1/alpha/search` (the native Codex web-search relay), `GET /v1/models`, and the standalone
realtime voice WebSocket upgrades. Everything else, including `/api/*` and the dashboard, returns
`404`.

:::danger[This is an unauthenticated surface]
Every process on the machine can use this listener. It spends account quota and paid provider
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,9 @@ tarafından atanmaz: geçici bir port yeniden başlatmalar arasında değişirke
zaten çalışan app-server'lar önceki `base_url`'i tutardı.

Dinleyici yalnızca `POST /v1/responses`, onun WebSocket yükseltmesi, `POST
/v1/responses/compact` ve `GET /v1/models` sunar. `/api/*` ve kontrol paneli
dahil diğer her şey `404` döndürür.
/v1/responses/compact`, `POST /v1/alpha/search` (yerel Codex web arama aktarımı),
`GET /v1/models` ve bağımsız sesli WebSocket yükseltmelerini sunar. `/api/*` ve
kontrol paneli dahil diğer her şey `404` döndürür.

:::danger[Bu kimliği doğrulanmamış bir yüzeydir]
Makinedeki her süreç bu dinleyiciyi kullanabilir. Hesap kotasını ve ücretli
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,9 @@ stream 開啟前以 `401` 失敗。
該 port 是必填的,且必須與 proxy port 不同。它絕不會由 OS 指派:臨時 port 會在重啟時改變,而
已執行的 app-server 仍保留先前的 `base_url`。

該 listener 只服務 `POST /v1/responses`、其 WebSocket upgrade、`POST /v1/responses/compact` 與
`GET /v1/models`。其他一切,包括 `/api/*` 與儀表板,都會回傳 `404`。
該 listener 只服務 `POST /v1/responses`、其 WebSocket upgrade、`POST /v1/responses/compact`、
`POST /v1/alpha/search`(Codex 原生網頁搜尋中繼)、`GET /v1/models`,以及獨立語音 WebSocket upgrade。
其他一切,包括 `/api/*` 與儀表板,都會回傳 `404`。

:::danger[這是一個未認證的介面]
機器上的每個 process 都可以使用此 listener。它會耗用帳號配額與付費 provider 憑證,也可能耗盡
Expand Down
10 changes: 8 additions & 2 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -780,8 +780,13 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
* Routes the unauthenticated loopback listener will serve. Everything else 404s.
*
* This is an allowlist rather than a filter applied to the public handler, because a filter
* inverts the failure mode: a route added later would be reachable here by default. The four
* entries are exactly what a directly-spawned `codex app-server` needs.
* inverts the failure mode: a route added later would be reachable here by default. The
* entries below are exactly what a directly-spawned `codex app-server` needs.
*
* `POST /v1/alpha/search` is the native Codex web-search relay. Codex issues it against the
* same base URL as `/v1/responses`, so leaving it off the list turned every native web search
* on the direct-spawn host into a 404 (#3192). The handler still runs its own admission, so a
* loopback caller without a ChatGPT credential is refused inside it rather than by this gate.
*
* `GET /v1/models` is on the list for a reason that is easy to miss. When catalog
* materialization fails or finds no source, `syncCodex` warns and injects with
Expand All @@ -795,6 +800,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
return req.method === "POST" || req.headers.get("upgrade")?.toLowerCase() === "websocket";
}
if (path === "/v1/responses/compact") return req.method === "POST";
if (path === "/v1/alpha/search") return req.method === "POST";
if (path === "/v1/models") return req.method === "GET";
// Standalone realtime voice sessions (codex-rs thread/realtime/start, WebSocket
// transport) — a directly-spawned `codex app-server` needs these for desktop
Expand Down
39 changes: 37 additions & 2 deletions tests/loopback-listener-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ describe("unauthenticated loopback listener", () => {
// 5s default on a loaded Windows box, where the test measured 5.04s.
}, SERVER_BUDGET_MS);

test("serves only the four allowlisted routes, using each route's real method", async () => {
test("serves only the allowlisted routes, using each route's real method", async () => {
const loopbackPort = await freePort();
saveConfig(baseConfig(loopbackPort));
const server = startServer(0);
Expand All @@ -285,13 +285,13 @@ describe("unauthenticated loopback listener", () => {
{ method: "POST", path: "/v1/chat/completions", body: '{"model":"x","messages":[]}' },
{ method: "POST", path: "/v1/messages", body: '{"model":"x","messages":[]}' },
{ method: "POST", path: "/v1/images/generations", body: '{"prompt":"x"}' },
{ method: "POST", path: "/v1/alpha/search", body: '{"query":"x"}' },
{ method: "GET", path: "/v1/opencodex/artifacts/x" },
{ method: "POST", path: "/v1/live", body: "{}" },
{ method: "POST", path: "/v1/realtime/calls", body: "{}" },
// Allowlisted paths still reject the methods they do not serve.
{ method: "DELETE", path: "/v1/responses" },
{ method: "POST", path: "/v1/models" },
{ method: "GET", path: "/v1/alpha/search" },
];
for (const { method, path, body } of denied) {
const res = await fetch(`${base}${path}`, {
Expand All @@ -309,6 +309,41 @@ describe("unauthenticated loopback listener", () => {
}
});

test("admits POST /v1/alpha/search so native web search reaches the relay (#3192)", async () => {
const loopbackPort = await freePort();
saveConfig(baseConfig(loopbackPort));
const server = startServer(0);
const body = '{"query":"x"}';
const headers = { "content-type": "application/json" };
try {
// Codex sends its native web-search call to the same base URL as /v1/responses. Before
// the allowlist admitted it, the direct-spawn host answered 404 for every search. What
// proves the gate is open is that the answer comes from BEHIND it: the admitted-turn
// path (503 while native-main maintenance holds, otherwise the relay's own 401 for a
// missing ChatGPT credential), never the listener's 404 and never the public
// listener's "opencodex API key required".
const viaLoopback = await fetch(`http://127.0.0.1:${loopbackPort}/v1/alpha/search`, {
method: "POST", body, headers,
});
const loopbackBody = await viaLoopback.json() as { error?: { message?: string } };
expect(viaLoopback.status).not.toBe(404);
expect([401, 503]).toContain(viaLoopback.status);
expect(loopbackBody.error?.message).toBeDefined();
expect(loopbackBody.error?.message).not.toBe("opencodex API key required");

// The public listener is unchanged: the same request without a key is still refused
// at admission, so widening the loopback allowlist did not widen the public surface.
const viaPublic = await fetch(`http://127.0.0.1:${server.port}/v1/alpha/search`, {
method: "POST", body, headers,
});
expect(viaPublic.status).toBe(401);
const publicBody = await viaPublic.json() as { error?: { message?: string } };
expect(publicBody.error?.message).toBe("opencodex API key required");
} finally {
await server.stop(true);
}
});

test("admits standalone realtime voice WebSocket upgrades, HTTP stays rejected", async () => {
const loopbackPort = await freePort();
saveConfig(baseConfig(loopbackPort));
Expand Down
Loading