Skip to content
5 changes: 5 additions & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
* `catalogPath: null`; Codex then builds an ONLINE model manager and `model/list` refreshes
* through `GET {base_url}/models`. Returning 404 there would leave the picker on its bundled
* fallback — fixing the direct-spawn host while breaking its model list.
*
* Exported Raycast and other OpenAI-compatible clients use Chat Completions. When this
* listener is selected as their loopback destination, that route must be admitted here too;
* the normal handler below still applies the same loopback policy and origin checks.
*/
function loopbackRouteAllowed(url: URL, req: Request): boolean {
const path = url.pathname;
Expand All @@ -824,6 +828,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
if (path === "/v1/images/generations" || path === "/v1/images/edits") {
return req.method === "POST";
}
if (path === "/v1/chat/completions") return req.method === "POST";
if (path === "/v1/models") return req.method === "GET";
// Realtime voice — a directly-spawned `codex app-server` needs these for desktop voice
// the same way it needs /v1/responses. Two shapes, same trust model as /v1/responses:
Expand Down
11 changes: 10 additions & 1 deletion tests/server/loopback-listener-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,6 @@ describe("unauthenticated loopback listener", () => {
{ method: "GET", path: "/" },
{ method: "GET", path: "/healthz" },
{ method: "GET", path: "/readyz" },
{ method: "POST", path: "/v1/chat/completions", body: '{"model":"x","messages":[]}' },
{ method: "POST", path: "/v1/messages", body: '{"model":"x","messages":[]}' },
{ method: "GET", path: "/v1/opencodex/artifacts/x" },
// Voice call-create is admitted only as POST; the keyed sideband join only as an upgrade.
Expand All @@ -316,6 +315,16 @@ describe("unauthenticated loopback listener", () => {
// And an allowlisted route is genuinely reachable, so the rejections above are not
// passing merely because nothing works on this listener.
expect((await fetch(`${base}/v1/models`)).status).toBe(200);

// Exported Raycast and other OpenAI-compatible clients target this listener and append
// /chat/completions to its /v1 base URL. The request must reach the normal handler rather
// than being rejected by the listener gate; the synthetic model can then fail normally.
const chatResponse = await fetch(`${base}/v1/chat/completions`, {
method: "POST",
body: '{"model":"x","messages":[]}',
headers: { "content-type": "application/json" },
});
expect(chatResponse.status).not.toBe(404);
} finally {
await server.stop(true);
}
Expand Down
Loading