Skip to content

Commit 0bd2515

Browse files
fix(gateway): reject unknown explicit agent selectors
Validate explicit OpenAI-compatible agent selectors against the configured roster so malformed integrations fail closed instead of creating phantom agent sessions. Co-Authored-By: Claude Opus 4.7 <[email protected]>
1 parent e3a6da0 commit 0bd2515

8 files changed

Lines changed: 218 additions & 37 deletions

src/gateway/embeddings-http.test.ts

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -278,21 +278,48 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
278278
});
279279

280280
it("supports base64 encoding and agent-scoped auth/config resolution", async () => {
281-
const res = await postEmbeddings(
282-
{
283-
model: "openclaw/beta",
284-
input: "hello",
285-
encoding_format: "base64",
286-
},
287-
{ "x-openclaw-agent-id": "beta" },
288-
);
289-
expect(res.status).toBe(200);
290-
const json = (await res.json()) as { data?: Array<{ embedding?: string }> };
291-
expect(typeof json.data?.[0]?.embedding).toBe("string");
292-
expect(createEmbeddingProviderMock).toHaveBeenCalled();
293-
const lastCall = latestCreateEmbeddingProviderOptions();
294-
expect(typeof lastCall.model).toBe("string");
295-
expect(lastCall.agentDir).toBe(resolveAgentDir({}, "beta"));
281+
try {
282+
testState.agentsConfig = { list: [{ id: "main" }, { id: "beta" }] };
283+
resetConfigRuntimeState();
284+
285+
const res = await postEmbeddings(
286+
{
287+
model: "openclaw/beta",
288+
input: "hello",
289+
encoding_format: "base64",
290+
},
291+
{ "x-openclaw-agent-id": "beta" },
292+
);
293+
expect(res.status).toBe(200);
294+
const json = (await res.json()) as { data?: Array<{ embedding?: string }> };
295+
expect(typeof json.data?.[0]?.embedding).toBe("string");
296+
expect(createEmbeddingProviderMock).toHaveBeenCalled();
297+
const lastCall = latestCreateEmbeddingProviderOptions();
298+
expect(typeof lastCall.model).toBe("string");
299+
expect(lastCall.agentDir).toBe(resolveAgentDir({}, "beta"));
300+
} finally {
301+
testState.agentsConfig = undefined;
302+
resetConfigRuntimeState();
303+
}
304+
});
305+
306+
it("rejects explicit unknown agent ids", async () => {
307+
try {
308+
testState.agentsConfig = { list: [{ id: "main" }, { id: "beta" }] };
309+
resetConfigRuntimeState();
310+
311+
const header = await postEmbeddings(
312+
{ model: "openclaw/default", input: "hello" },
313+
{ "x-openclaw-agent-id": "missing-agent" },
314+
);
315+
await expectInvalidEmbeddingRequest(header, "Unknown agent 'missing-agent'.");
316+
317+
const model = await postEmbeddings({ model: "openclaw/missing-agent", input: "hello" });
318+
await expectInvalidEmbeddingRequest(model, "Unknown agent 'missing-agent'.");
319+
} finally {
320+
testState.agentsConfig = undefined;
321+
resetConfigRuntimeState();
322+
}
296323
});
297324

298325
it("rejects invalid input shapes", async () => {

src/gateway/embeddings-http.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
2929
import {
3030
OPENCLAW_MODEL_ID,
3131
getHeader,
32+
isUnknownGatewayAgentError,
3233
resolveAgentIdForRequest,
3334
resolveAgentIdFromModel,
3435
resolveOpenAiCompatibleHttpOperatorScopes,
@@ -291,7 +292,18 @@ export async function handleOpenAiEmbeddingsHttpRequest(
291292
return true;
292293
}
293294

294-
const agentId = resolveAgentIdForRequest({ req, model: requestModel });
295+
let agentId: string;
296+
try {
297+
agentId = resolveAgentIdForRequest({ req, model: requestModel });
298+
} catch (err) {
299+
if (isUnknownGatewayAgentError(err)) {
300+
sendJson(res, 400, {
301+
error: { message: err.message, type: "invalid_request_error" },
302+
});
303+
return true;
304+
}
305+
throw err;
306+
}
295307
const agentDir = resolveAgentDir(cfg, agentId);
296308
const memorySearch = resolveMemorySearchConfig(cfg, agentId);
297309
const configuredProvider = memorySearch?.provider ?? "openai";

src/gateway/http-utils.request-context.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,26 @@ describe("resolveGatewayRequestContext", () => {
5454

5555
expect(result.sessionKey).toContain("openresponses-user:alice");
5656
});
57+
58+
it("does not build session state for explicit unknown agent ids", () => {
59+
expect(() =>
60+
resolveGatewayRequestContext({
61+
req: createReq({ "x-openclaw-agent-id": "missing-agent" }),
62+
model: "openclaw",
63+
sessionPrefix: "openai",
64+
defaultMessageChannel: "webchat",
65+
}),
66+
).toThrow(/Unknown agent/);
67+
68+
expect(() =>
69+
resolveGatewayRequestContext({
70+
req: createReq(),
71+
model: "openclaw/missing-agent",
72+
sessionPrefix: "openai",
73+
defaultMessageChannel: "webchat",
74+
}),
75+
).toThrow(/Unknown agent/);
76+
});
5777
});
5878

5979
describe("resolveTrustedHttpOperatorScopes", () => {

src/gateway/http-utils.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
normalizeLowercaseStringOrEmpty,
77
normalizeOptionalString,
88
} from "@openclaw/normalization-core/string-coerce";
9-
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
9+
import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js";
1010
import { modelKey, parseModelRef, resolveDefaultModelForAgent } from "../agents/model-selection.js";
1111
import { createModelVisibilityPolicy } from "../agents/model-visibility-policy.js";
1212
import { getRuntimeConfig } from "../config/io.js";
@@ -37,6 +37,23 @@ export const OPENCLAW_MODEL_ID = "openclaw";
3737
/** Default OpenAI-compatible model alias that targets the default OpenClaw agent. */
3838
export const OPENCLAW_DEFAULT_MODEL_ID = "openclaw/default";
3939

40+
export class UnknownGatewayAgentError extends Error {
41+
constructor(readonly agentId: string) {
42+
super(`Unknown agent '${agentId}'.`);
43+
this.name = "UnknownGatewayAgentError";
44+
}
45+
}
46+
47+
export function isUnknownGatewayAgentError(err: unknown): err is UnknownGatewayAgentError {
48+
return err instanceof UnknownGatewayAgentError;
49+
}
50+
51+
function assertKnownAgentId(agentId: string, cfg = getRuntimeConfig()): void {
52+
if (!listAgentIds(cfg).includes(agentId)) {
53+
throw new UnknownGatewayAgentError(agentId);
54+
}
55+
}
56+
4057
function resolveAgentIdFromHeader(req: IncomingMessage): string | undefined {
4158
const raw =
4259
normalizeOptionalString(getHeader(req, "x-openclaw-agent-id")) ||
@@ -139,11 +156,17 @@ export function resolveAgentIdForRequest(params: {
139156
const cfg = getRuntimeConfig();
140157
const fromHeader = resolveAgentIdFromHeader(params.req);
141158
if (fromHeader) {
159+
assertKnownAgentId(fromHeader, cfg);
142160
return fromHeader;
143161
}
144162

145163
const fromModel = resolveAgentIdFromModel(params.model, cfg);
146-
return fromModel ?? resolveDefaultAgentId(cfg);
164+
if (fromModel) {
165+
assertKnownAgentId(fromModel, cfg);
166+
return fromModel;
167+
}
168+
169+
return resolveDefaultAgentId(cfg);
147170
}
148171

149172
function resolveSessionKey(params: {

src/gateway/openai-http.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { subscribeEmbeddedAgentSession } from "../agents/embedded-agent-subscrib
1313
import { FailoverError } from "../agents/failover-error.js";
1414
import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js";
1515
import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
16+
import { resetConfigRuntimeState } from "../config/config.js";
1617
import { emitAgentEvent } from "../infra/agent-events.js";
1718
import { buildAssistantDeltaResult } from "./test-helpers.agent-results.js";
1819
import {
@@ -187,6 +188,9 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
187188
};
188189

189190
try {
191+
testState.agentsConfig = { list: [{ id: "main" }, { id: "beta" }] };
192+
resetConfigRuntimeState();
193+
190194
{
191195
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
192196
method: "GET",
@@ -233,6 +237,33 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
233237
matcher: /^agent:main:/,
234238
});
235239

240+
{
241+
agentCommand.mockClear();
242+
const res = await postChatCompletions(
243+
port,
244+
{ model: "openclaw", messages: [{ role: "user", content: "hi" }] },
245+
{ "x-openclaw-agent-id": "missing-agent" },
246+
);
247+
expect(res.status).toBe(400);
248+
const json = (await res.json()) as { error?: { type?: string; message?: string } };
249+
expect(json.error?.type).toBe("invalid_request_error");
250+
expect(json.error?.message).toBe("Unknown agent 'missing-agent'.");
251+
expect(agentCommand).toHaveBeenCalledTimes(0);
252+
}
253+
254+
{
255+
agentCommand.mockClear();
256+
const res = await postChatCompletions(port, {
257+
model: "openclaw/missing-agent",
258+
messages: [{ role: "user", content: "hi" }],
259+
});
260+
expect(res.status).toBe(400);
261+
const json = (await res.json()) as { error?: { type?: string; message?: string } };
262+
expect(json.error?.type).toBe("invalid_request_error");
263+
expect(json.error?.message).toBe("Unknown agent 'missing-agent'.");
264+
expect(agentCommand).toHaveBeenCalledTimes(0);
265+
}
266+
236267
{
237268
mockAgentOnce([{ text: "hello" }]);
238269
const res = await postChatCompletions(
@@ -1397,7 +1428,8 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
13971428
);
13981429
}
13991430
} finally {
1400-
// shared server
1431+
testState.agentsConfig = undefined;
1432+
resetConfigRuntimeState();
14011433
}
14021434
});
14031435

src/gateway/openai-http.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import type { ResolvedGatewayAuth } from "./auth.js";
4545
import { sendJson, setSseHeaders, watchClientDisconnect, writeDone } from "./http-common.js";
4646
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
4747
import {
48+
isUnknownGatewayAgentError,
4849
resolveGatewayRequestContext,
4950
resolveOpenAiCompatModelOverride,
5051
resolveOpenAiCompatibleHttpOperatorScopes,
@@ -962,14 +963,27 @@ export async function handleOpenAiHttpRequest(
962963
}
963964
: undefined;
964965

965-
const { agentId, sessionKey, messageChannel } = resolveGatewayRequestContext({
966-
req,
967-
model,
968-
user,
969-
sessionPrefix: "openai",
970-
defaultMessageChannel: "webchat",
971-
useMessageChannelHeader: true,
972-
});
966+
let agentId: string;
967+
let sessionKey: string;
968+
let messageChannel: string;
969+
try {
970+
({ agentId, sessionKey, messageChannel } = resolveGatewayRequestContext({
971+
req,
972+
model,
973+
user,
974+
sessionPrefix: "openai",
975+
defaultMessageChannel: "webchat",
976+
useMessageChannelHeader: true,
977+
}));
978+
} catch (err) {
979+
if (isUnknownGatewayAgentError(err)) {
980+
sendJson(res, 400, {
981+
error: { message: err.message, type: "invalid_request_error" },
982+
});
983+
return true;
984+
}
985+
throw err;
986+
}
973987
const { modelOverride, errorMessage: modelError } = await resolveOpenAiCompatModelOverride({
974988
req,
975989
agentId,

src/gateway/openresponses-http.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ import { createClientToolNameConflictError } from "../agents/agent-tool-definiti
88
import { FailoverError } from "../agents/failover-error.js";
99
import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js";
1010
import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
11+
import { resetConfigRuntimeState } from "../config/config.js";
1112
import { emitAgentEvent } from "../infra/agent-events.js";
1213
import { buildAssistantDeltaResult } from "./test-helpers.agent-results.js";
1314
import {
1415
agentCommand,
1516
getFreePort,
1617
installGatewayTestHooks,
1718
startGatewayServerWithRetries,
19+
testState,
1820
} from "./test-helpers.js";
1921

2022
installGatewayTestHooks({ scope: "suite" });
@@ -263,6 +265,9 @@ describe("OpenResponses HTTP API (e2e)", () => {
263265
};
264266

265267
try {
268+
testState.agentsConfig = { list: [{ id: "main" }, { id: "beta" }] };
269+
resetConfigRuntimeState();
270+
266271
const resNonPost = await fetch(`http://127.0.0.1:${port}/v1/responses`, {
267272
method: "GET",
268273
headers: { authorization: "Bearer secret" },
@@ -333,6 +338,30 @@ describe("OpenResponses HTTP API (e2e)", () => {
333338
);
334339
await ensureResponseConsumed(resDefaultAlias);
335340

341+
{
342+
agentCommand.mockClear();
343+
const res = await postResponses(
344+
port,
345+
{ model: "openclaw", input: "hi" },
346+
{ "x-openclaw-agent-id": "missing-agent" },
347+
);
348+
expect(res.status).toBe(400);
349+
const json = (await res.json()) as { error?: { type?: string; message?: string } };
350+
expect(json.error?.type).toBe("invalid_request_error");
351+
expect(json.error?.message).toBe("Unknown agent 'missing-agent'.");
352+
expect(agentCommand).toHaveBeenCalledTimes(0);
353+
}
354+
355+
{
356+
agentCommand.mockClear();
357+
const res = await postResponses(port, { model: "openclaw/missing-agent", input: "hi" });
358+
expect(res.status).toBe(400);
359+
const json = (await res.json()) as { error?: { type?: string; message?: string } };
360+
expect(json.error?.type).toBe("invalid_request_error");
361+
expect(json.error?.message).toBe("Unknown agent 'missing-agent'.");
362+
expect(agentCommand).toHaveBeenCalledTimes(0);
363+
}
364+
336365
mockAgentOnce([{ text: "hello" }]);
337366
const resChannelHeader = await postResponses(
338367
port,
@@ -797,7 +826,8 @@ describe("OpenResponses HTTP API (e2e)", () => {
797826
);
798827
await ensureResponseConsumed(resNoUser);
799828
} finally {
800-
// shared server
829+
testState.agentsConfig = undefined;
830+
resetConfigRuntimeState();
801831
}
802832
});
803833

0 commit comments

Comments
 (0)