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
7 changes: 7 additions & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
* 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.
*
* The standalone Images client uses the same base URL for its two POST routes. Their handler
* keeps the paid upstream behind its own admission and forward-credential checks, so admit only
* the exact methods and paths it serves (#3428).
*
* `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
* `catalogPath: null`; Codex then builds an ONLINE model manager and `model/list` refreshes
Expand All @@ -811,6 +815,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
}
if (path === "/v1/responses/compact") return req.method === "POST";
if (path === "/v1/alpha/search") return req.method === "POST";
if (path === "/v1/images/generations" || path === "/v1/images/edits") {
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
37 changes: 36 additions & 1 deletion tests/loopback-listener-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,6 @@ describe("unauthenticated loopback listener", () => {
{ method: "GET", path: "/readyz" },
{ 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: "GET", path: "/v1/opencodex/artifacts/x" },
// Voice call-create is admitted only as POST; the keyed sideband join only as an upgrade.
{ method: "GET", path: "/v1/live/rtc_x" },
Expand All @@ -296,6 +295,8 @@ describe("unauthenticated loopback listener", () => {
{ method: "DELETE", path: "/v1/responses" },
{ method: "POST", path: "/v1/models" },
{ method: "GET", path: "/v1/alpha/search" },
{ method: "GET", path: "/v1/images/generations" },
{ method: "GET", path: "/v1/images/edits" },
];
for (const { method, path, body } of denied) {
const res = await fetch(`${base}${path}`, {
Expand Down Expand Up @@ -348,6 +349,40 @@ describe("unauthenticated loopback listener", () => {
}
});

test("admits the exact standalone Images POST routes so they reach the relay (#3428)", async () => {
const loopbackPort = await freePort();
saveConfig(baseConfig(loopbackPort));
const server = startServer(0);
const headers = { "content-type": "application/json" };
try {
for (const path of ["/v1/images/generations", "/v1/images/edits"]) {
const viaLoopback = await fetch(`http://127.0.0.1:${loopbackPort}${path}`, {
method: "POST",
body: '{"prompt":"x"}',
headers,
});
const loopbackBody = await viaLoopback.json() as { error?: { message?: string } };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
expect(viaLoopback.status).not.toBe(404);
expect([400, 503]).toContain(viaLoopback.status);
expect(loopbackBody.error?.message).toBeDefined();
expect(loopbackBody.error?.message).not.toBe("opencodex API key required");

// The public listener remains credential-gated. Only the separately bound loopback
// listener gets the narrow route exception.
const viaPublic = await fetch(`http://127.0.0.1:${server.port}${path}`, {
method: "POST",
body: '{"prompt":"x"}',
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