Skip to content

Commit 02eb3c9

Browse files
committed
fix: require admin for HTTP model overrides
1 parent 7c08804 commit 02eb3c9

11 files changed

Lines changed: 168 additions & 14 deletions

docs/gateway/openai-http-api.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ Auth matrix:
7575
- honor `x-openclaw-scopes` when the header is present
7676
- fall back to the normal operator default scope set when the header is absent
7777
- only lose owner semantics when the caller explicitly narrows scopes and omits `operator.admin`
78+
- require `operator.admin` for owner-level request controls such as `x-openclaw-model`
7879

7980
See [Security](/gateway/security) and [Remote access](/gateway/remote).
8081

@@ -96,7 +97,7 @@ OpenClaw treats the OpenAI `model` field as an **agent target**, not a raw provi
9697

9798
Optional request headers:
9899

99-
- `x-openclaw-model: <provider/model-or-bare-id>` overrides the backend model for the selected agent.
100+
- `x-openclaw-model: <provider/model-or-bare-id>` overrides the backend model for the selected agent. Shared-secret bearer callers can use this header. Identity-bearing callers, such as trusted-proxy or private no-auth ingress requests with `x-openclaw-scopes`, need `operator.admin`; write-only callers get `403 missing scope: operator.admin`.
100101
- `x-openclaw-agent-id: <agentId>` remains supported as a compatibility override.
101102
- `x-openclaw-session-key: <sessionKey>` fully controls session routing.
102103
- `x-openclaw-message-channel: <channel>` sets the synthetic ingress channel context for channel-aware prompts and policies.
@@ -178,7 +179,7 @@ This is the highest-leverage compatibility set for self-hosted frontends and too
178179

179180
</Accordion>
180181
<Accordion title="How do I override the backend model?">
181-
Use `x-openclaw-model`.
182+
Use `x-openclaw-model`. This is an owner-level override: it works with the Gateway shared-secret bearer token/password path, and it requires `operator.admin` on identity-bearing HTTP paths such as trusted proxy auth.
182183

183184
Examples:
184185
`x-openclaw-model: openai/gpt-5.4`
@@ -191,7 +192,7 @@ This is the highest-leverage compatibility set for self-hosted frontends and too
191192
`/v1/embeddings` uses the same agent-target `model` ids.
192193

193194
Use `model: "openclaw/default"` or `model: "openclaw/<agentId>"`.
194-
When you need a specific embedding model, send it in `x-openclaw-model`.
195+
When you need a specific embedding model, send it in `x-openclaw-model` from a shared-secret caller or an identity-bearing caller with `operator.admin`.
195196
Without that header, the request passes through to the selected agent's normal embedding setup.
196197

197198
</Accordion>
@@ -285,7 +286,7 @@ Expected behavior:
285286

286287
- `GET /v1/models` should list `openclaw/default`
287288
- Open WebUI should use `openclaw/default` as the chat model id
288-
- If you want a specific backend provider/model for that agent, set the agent's normal default model or send `x-openclaw-model`
289+
- If you want a specific backend provider/model for that agent, set the agent's normal default model or send `x-openclaw-model` from a shared-secret caller or an identity-bearing caller with `operator.admin`
289290

290291
Quick smoke:
291292

@@ -370,7 +371,7 @@ Notes:
370371

371372
- `/v1/models` returns OpenClaw agent targets, not raw provider catalogs.
372373
- `openclaw/default` is always present so one stable id works across environments.
373-
- Backend provider/model overrides belong in `x-openclaw-model`, not the OpenAI `model` field.
374+
- Backend provider/model overrides belong in `x-openclaw-model`, not the OpenAI `model` field. On identity-bearing HTTP auth paths, this header requires `operator.admin`.
374375
- `/v1/embeddings` supports `input` as a string or array of strings.
375376

376377
## Related

docs/gateway/security/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -951,7 +951,7 @@ Important boundary note:
951951
- Treat credentials that can call `/v1/chat/completions`, `/v1/responses`, plugin routes such as `/api/v1/admin/rpc`, or `/api/channels/*` as full-access operator secrets for that gateway.
952952
- On the OpenAI-compatible HTTP surface, shared-secret bearer auth restores the full default operator scopes (`operator.admin`, `operator.approvals`, `operator.pairing`, `operator.read`, `operator.talk.secrets`, `operator.write`) and owner semantics for agent turns; narrower `x-openclaw-scopes` values do not reduce that shared-secret path.
953953
- Per-request scope semantics on HTTP only apply when the request comes from an identity-bearing mode such as trusted proxy auth, or from an explicitly no-auth private ingress.
954-
- In those identity-bearing modes, omitting `x-openclaw-scopes` falls back to the normal operator default scope set; send the header explicitly when you want a narrower scope set.
954+
- In those identity-bearing modes, omitting `x-openclaw-scopes` falls back to the normal operator default scope set; send the header explicitly when you want a narrower scope set. Owner-level OpenAI-compatible headers such as `x-openclaw-model` require `operator.admin` when scopes are narrowed.
955955
- `/tools/invoke` and HTTP session history endpoints follow the same shared-secret rule: token/password bearer auth is treated as full operator access there too, while identity-bearing modes still honor declared scopes.
956956
- Do not share these credentials with untrusted callers; prefer separate gateways per trust boundary.
957957

src/gateway/embeddings-http.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,38 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
429429
);
430430
});
431431

432+
it("rejects x-openclaw-model for trusted write-only callers", async () => {
433+
const port = await getFreePort();
434+
const server = await startOpenAiCompatGatewayServer({
435+
startGatewayServer,
436+
port,
437+
auth: { mode: "none" },
438+
openAiChatCompletionsEnabled: true,
439+
});
440+
try {
441+
createEmbeddingProviderMock.mockClear();
442+
const res = await fetch(`http://127.0.0.1:${port}/v1/embeddings`, {
443+
method: "POST",
444+
headers: {
445+
"content-type": "application/json",
446+
"x-openclaw-scopes": "operator.write",
447+
"x-openclaw-model": "openai/text-embedding-3-small",
448+
},
449+
body: JSON.stringify({
450+
model: "openclaw/default",
451+
input: "hello",
452+
}),
453+
});
454+
expect(res.status).toBe(403);
455+
const json = (await res.json()) as { error?: { type?: string; message?: string } };
456+
expect(json.error?.type).toBe("forbidden");
457+
expect(json.error?.message).toBe("missing scope: operator.admin");
458+
expect(createEmbeddingProviderMock).not.toHaveBeenCalled();
459+
} finally {
460+
await server.close({ reason: "embeddings model override auth test done" });
461+
}
462+
});
463+
432464
it("rejects oversized batches", async () => {
433465
const res = await postEmbeddings({
434466
model: "openclaw/default",

src/gateway/embeddings-http.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,11 @@ import type {
2424
} from "../plugins/memory-embedding-providers.js";
2525
import type { AuthRateLimiter } from "./auth-rate-limit.js";
2626
import type { ResolvedGatewayAuth } from "./auth.js";
27-
import { sendJson } from "./http-common.js";
27+
import { sendJson, sendMissingScopeForbidden } from "./http-common.js";
2828
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
2929
import {
3030
OPENCLAW_MODEL_ID,
31+
authorizeOpenAiCompatibleHttpModelOverride,
3132
getHeader,
3233
resolveAgentIdForRequest,
3334
resolveAgentIdFromModel,
@@ -252,6 +253,11 @@ export async function handleOpenAiEmbeddingsHttpRequest(
252253
if (!handled) {
253254
return true;
254255
}
256+
const modelOverrideAuth = authorizeOpenAiCompatibleHttpModelOverride(req, handled.requestAuth);
257+
if (!modelOverrideAuth.allowed) {
258+
sendMissingScopeForbidden(res, modelOverrideAuth.missingScope);
259+
return true;
260+
}
255261

256262
const payload = coerceRequest(handled.body);
257263
const requestModel = normalizeOptionalString(payload.model) ?? "";

src/gateway/http-auth-utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,3 +260,14 @@ export function resolveOpenAiCompatibleHttpSenderIsOwner(
260260
}
261261
return resolveHttpSenderIsOwner(req, requestAuth);
262262
}
263+
264+
export function authorizeOpenAiCompatibleHttpModelOverride(
265+
req: IncomingMessage,
266+
requestAuth: AuthorizedGatewayHttpRequest,
267+
): { allowed: true } | { allowed: false; missingScope: typeof ADMIN_SCOPE } {
268+
const requestedModelOverride = normalizeOptionalString(getHeader(req, "x-openclaw-model"));
269+
if (!requestedModelOverride || resolveOpenAiCompatibleHttpSenderIsOwner(req, requestAuth)) {
270+
return { allowed: true };
271+
}
272+
return { allowed: false, missingScope: ADMIN_SCOPE };
273+
}

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import type { IncomingMessage } from "node:http";
55
import { describe, expect, it } from "vitest";
66
import {
7+
authorizeOpenAiCompatibleHttpModelOverride,
78
resolveOpenAiCompatibleHttpOperatorScopes,
89
resolveOpenAiCompatibleHttpSenderIsOwner,
910
resolveGatewayRequestContext,
@@ -188,3 +189,38 @@ describe("resolveOpenAiCompatibleHttpSenderIsOwner", () => {
188189
).toBe(true);
189190
});
190191
});
192+
193+
describe("authorizeOpenAiCompatibleHttpModelOverride", () => {
194+
it("allows shared-secret bearer callers to use x-openclaw-model", () => {
195+
expect(
196+
authorizeOpenAiCompatibleHttpModelOverride(
197+
createReq({ authorization: "Bearer secret", "x-openclaw-model": "openai/gpt-5.4" }),
198+
{ authMethod: "token", trustDeclaredOperatorScopes: false },
199+
),
200+
).toEqual({ allowed: true });
201+
});
202+
203+
it("allows trusted admin callers to use x-openclaw-model", () => {
204+
expect(
205+
authorizeOpenAiCompatibleHttpModelOverride(
206+
createReq({
207+
"x-openclaw-scopes": "operator.admin, operator.write",
208+
"x-openclaw-model": "openai/gpt-5.4",
209+
}),
210+
{ authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true },
211+
),
212+
).toEqual({ allowed: true });
213+
});
214+
215+
it("rejects trusted write-only callers that try to use x-openclaw-model", () => {
216+
expect(
217+
authorizeOpenAiCompatibleHttpModelOverride(
218+
createReq({
219+
"x-openclaw-scopes": "operator.write",
220+
"x-openclaw-model": "openai/gpt-5.4",
221+
}),
222+
{ authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true },
223+
),
224+
).toEqual({ allowed: false, missingScope: "operator.admin" });
225+
});
226+
});

src/gateway/http-utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { getHeader } from "./http-auth-utils.js";
1717
import { loadGatewayModelCatalog } from "./server-model-catalog.js";
1818

1919
export {
20+
authorizeOpenAiCompatibleHttpModelOverride,
2021
authorizeGatewayHttpRequestOrReply,
2122
authorizeScopedGatewayHttpRequestOrReply,
2223
checkGatewayHttpRequestAuth,

src/gateway/openai-http.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
287287
},
288288
{
289289
"x-openclaw-model": "openai/gpt-5.4",
290+
"x-openclaw-scopes": "operator.admin, operator.write",
290291
},
291292
);
292293
expect(res.status).toBe(200);
@@ -314,6 +315,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
314315
},
315316
{
316317
"x-openclaw-model": "gpt-5.4",
318+
"x-openclaw-scopes": "operator.admin, operator.write",
317319
},
318320
);
319321
expect(res.status).toBe(200);
@@ -345,7 +347,27 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
345347
model: "openclaw",
346348
messages: [{ role: "user", content: "hi" }],
347349
},
348-
{ "x-openclaw-model": "openai/" },
350+
{ "x-openclaw-model": "openai/gpt-5.4" },
351+
);
352+
expect(res.status).toBe(403);
353+
const json = (await res.json()) as { error?: { message?: string; type?: string } };
354+
expect(json.error?.type).toBe("forbidden");
355+
expect(json.error?.message).toBe("missing scope: operator.admin");
356+
expect(agentCommand).toHaveBeenCalledTimes(0);
357+
}
358+
359+
{
360+
agentCommand.mockClear();
361+
const res = await postChatCompletions(
362+
port,
363+
{
364+
model: "openclaw",
365+
messages: [{ role: "user", content: "hi" }],
366+
},
367+
{
368+
"x-openclaw-model": "openai/",
369+
"x-openclaw-scopes": "operator.admin, operator.write",
370+
},
349371
);
350372
expect(res.status).toBe(400);
351373
const json = (await res.json()) as { error?: { type?: string; message?: string } };

src/gateway/openai-http.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,16 @@ import {
4242
} from "./agent-prompt.js";
4343
import type { AuthRateLimiter } from "./auth-rate-limit.js";
4444
import type { ResolvedGatewayAuth } from "./auth.js";
45-
import { sendJson, setSseHeaders, watchClientDisconnect, writeDone } from "./http-common.js";
45+
import {
46+
sendJson,
47+
sendMissingScopeForbidden,
48+
setSseHeaders,
49+
watchClientDisconnect,
50+
writeDone,
51+
} from "./http-common.js";
4652
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
4753
import {
54+
authorizeOpenAiCompatibleHttpModelOverride,
4855
resolveGatewayRequestContext,
4956
resolveOpenAiCompatModelOverride,
5057
resolveOpenAiCompatibleHttpOperatorScopes,
@@ -165,7 +172,7 @@ function buildAgentCommandInput(params: {
165172
deliver: false as const,
166173
messageChannel: params.messageChannel,
167174
bestEffortDeliver: false as const,
168-
allowModelOverride: true as const,
175+
allowModelOverride: params.modelOverride !== undefined,
169176
abortSignal: params.abortSignal,
170177
streamParams: params.streamParams,
171178
};
@@ -886,6 +893,11 @@ export async function handleOpenAiHttpRequest(
886893
if (!handled) {
887894
return true;
888895
}
896+
const modelOverrideAuth = authorizeOpenAiCompatibleHttpModelOverride(req, handled.requestAuth);
897+
if (!modelOverrideAuth.allowed) {
898+
sendMissingScopeForbidden(res, modelOverrideAuth.missingScope);
899+
return true;
900+
}
889901
const payload = coerceRequest(handled.body);
890902
const stream = Boolean(payload.stream);
891903
const streamIncludeUsage = stream && resolveIncludeUsageForStreaming(payload);

src/gateway/openresponses-http.test.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,10 @@ describe("OpenResponses HTTP API (e2e)", () => {
353353
model: "openclaw",
354354
input: "hi",
355355
},
356-
{ "x-openclaw-model": "openai/gpt-5.4" },
356+
{
357+
"x-openclaw-model": "openai/gpt-5.4",
358+
"x-openclaw-scopes": "operator.admin, operator.write",
359+
},
357360
);
358361
expect(resModelOverride.status).toBe(200);
359362
const optsModelOverride = firstAgentOpts();
@@ -364,7 +367,10 @@ describe("OpenResponses HTTP API (e2e)", () => {
364367
const resInvalidOverride = await postResponses(
365368
port,
366369
{ model: "openclaw", input: "hi" },
367-
{ "x-openclaw-model": "openai/" },
370+
{
371+
"x-openclaw-model": "openai/",
372+
"x-openclaw-scopes": "operator.admin, operator.write",
373+
},
368374
);
369375
expect(resInvalidOverride.status).toBe(400);
370376
const invalidOverrideJson = (await resInvalidOverride.json()) as {
@@ -375,6 +381,21 @@ describe("OpenResponses HTTP API (e2e)", () => {
375381
expect(agentCommand).toHaveBeenCalledTimes(0);
376382
await ensureResponseConsumed(resInvalidOverride);
377383

384+
agentCommand.mockClear();
385+
const resWriteOnlyOverride = await postResponses(
386+
port,
387+
{ model: "openclaw", input: "hi" },
388+
{ "x-openclaw-model": "openai/gpt-5.4" },
389+
);
390+
expect(resWriteOnlyOverride.status).toBe(403);
391+
const writeOnlyJson = (await resWriteOnlyOverride.json()) as {
392+
error?: { type?: string; message?: string };
393+
};
394+
expect(writeOnlyJson.error?.type).toBe("forbidden");
395+
expect(writeOnlyJson.error?.message).toBe("missing scope: operator.admin");
396+
expect(agentCommand).toHaveBeenCalledTimes(0);
397+
await ensureResponseConsumed(resWriteOnlyOverride);
398+
378399
agentCommand.mockClear();
379400
agentCommand.mockRejectedValueOnce(createClientToolNameConflictError(["exec"]));
380401
const resToolConflict = await postResponses(port, {

0 commit comments

Comments
 (0)