fix(gateway): restore compat HTTP operator auth

This commit is contained in:
Peter Steinberger
2026-03-31 16:48:15 +09:00
parent 6eb42593fa
commit 0633406ff6
18 changed files with 369 additions and 75 deletions

View File

@@ -57,6 +57,7 @@ These are frequently reported but are typically closed with no code change:
- Reports that only show a malicious plugin executing privileged actions after a trusted operator installs/enables it.
- Reports that assume per-user multi-tenant authorization on a shared gateway host/config.
- Reports that treat the Gateway HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) as if they implemented scoped operator auth (`operator.write` vs `operator.admin`). These endpoints authenticate the shared Gateway bearer secret/password and are documented full operator-access surfaces, not per-user/per-scope boundaries.
- Reports that assume `x-openclaw-scopes` can reduce or redefine shared-secret bearer auth on the OpenAI-compatible HTTP endpoints. For shared-secret auth (`gateway.auth.mode="token"` or `"password"`), those endpoints ignore narrower bearer-declared scopes and restore the full default operator scope set plus owner semantics.
- Reports that only show differences in heuristic detection/parity (for example obfuscation-pattern detection on one exec path but not another, such as `node.invoke -> system.run` parity gaps) without demonstrating bypass of auth, approvals, allowlist enforcement, sandboxing, or other documented trust boundaries.
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
- Archive/install extraction claims that require pre-existing local filesystem priming in trusted state (for example planting symlink/hardlink aliases under destination directories such as skills/tools paths) without showing an untrusted path that can create/control that primitive.
@@ -94,6 +95,12 @@ OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boun
- Authenticated Gateway callers are treated as trusted operators for that gateway instance.
- The HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) are in that same trusted-operator bucket. Passing Gateway bearer auth there is equivalent to operator access for that gateway; they do not implement a narrower `operator.write` vs `operator.admin` trust split.
- Concretely, on the OpenAI-compatible HTTP surface:
- shared-secret bearer auth (`token` / `password`) authenticates possession of the gateway operator secret
- those requests receive the full default operator scope set (`operator.admin`, `operator.read`, `operator.write`, `operator.approvals`, `operator.pairing`)
- chat-turn endpoints (`/v1/chat/completions`, `/v1/responses`) also treat those shared-secret callers as owner senders for owner-only tool policy
- narrower `x-openclaw-scopes` headers are ignored for that shared-secret path
- only identity-bearing HTTP modes (for example trusted proxy auth or `gateway.auth.mode="none"` on private ingress) honor declared per-request operator scopes
- Session identifiers (`sessionKey`, session IDs, labels) are routing controls, not per-user authorization boundaries.
- If one operator can view data from another operator on the same gateway, that is expected in this trust model.
- OpenClaw can technically run multiple gateway instances on one machine, but recommended operations are clean separation by trust boundary.

View File

@@ -43,9 +43,23 @@ Treat this endpoint as a **full operator-access** surface for the gateway instan
- A valid Gateway token/password for this endpoint should be treated like an owner/operator credential.
- Requests run through the same control-plane agent path as trusted operator actions.
- There is no separate non-owner/per-user tool boundary on this endpoint; once a caller passes Gateway auth here, OpenClaw treats that caller as a trusted operator for this gateway.
- For shared-secret auth modes (`token` and `password`), the endpoint restores the normal full operator defaults even if the caller sends a narrower `x-openclaw-scopes` header.
- Trusted identity-bearing HTTP modes (for example trusted proxy auth or `gateway.auth.mode="none"`) still honor the declared operator scopes on the request.
- If the target agent policy allows sensitive tools, this endpoint can use them.
- Keep this endpoint on loopback/tailnet/private ingress only; do not expose it directly to the public internet.
Auth matrix:
- `gateway.auth.mode="token"` or `"password"` + `Authorization: Bearer ...`
- proves possession of the shared gateway operator secret
- ignores narrower `x-openclaw-scopes`
- restores the full default operator scope set
- treats chat turns on this endpoint as owner-sender turns
- trusted identity-bearing HTTP modes (for example trusted proxy auth, or `gateway.auth.mode="none"` on private ingress)
- authenticate some outer trusted identity or deployment boundary
- honor the declared `x-openclaw-scopes` header
- only get owner semantics when `operator.admin` is actually present in those declared scopes
See [Security](/gateway/security) and [Remote access](/gateway/remote).
## Agent-first model contract

View File

@@ -24,11 +24,24 @@ Operational behavior matches [OpenAI Chat Completions](/gateway/openai-http-api)
- use `Authorization: Bearer <token>` with the normal Gateway auth config
- treat the endpoint as full operator access for the gateway instance
- for shared-secret auth modes (`token` and `password`), ignore narrower bearer-declared `x-openclaw-scopes` values and restore the normal full operator defaults
- for trusted identity-bearing HTTP modes (for example trusted proxy auth or `gateway.auth.mode="none"`), still honor the declared operator scopes on the request
- select agents with `model: "openclaw"`, `model: "openclaw/default"`, `model: "openclaw/<agentId>"`, or `x-openclaw-agent-id`
- use `x-openclaw-model` when you want to override the selected agent's backend model
- use `x-openclaw-session-key` for explicit session routing
- use `x-openclaw-message-channel` when you want a non-default synthetic ingress channel context
Auth matrix:
- `gateway.auth.mode="token"` or `"password"` + `Authorization: Bearer ...`
- proves possession of the shared gateway operator secret
- ignores narrower `x-openclaw-scopes`
- restores the full default operator scope set
- treats chat turns on this endpoint as owner-sender turns
- trusted identity-bearing HTTP modes (for example trusted proxy auth, or `gateway.auth.mode="none"` on private ingress)
- honor the declared `x-openclaw-scopes` header
- only get owner semantics when `operator.admin` is actually present in those declared scopes
Enable or disable this endpoint with `gateway.http.endpoints.responses.enabled`.
The same compatibility surface also includes:

View File

@@ -802,7 +802,10 @@ still require token/password auth.
Important boundary note:
- Gateway HTTP bearer auth is effectively all-or-nothing operator access.
- Treat credentials that can call `/v1/chat/completions`, `/v1/responses`, `/tools/invoke`, or `/api/channels/*` as full-access operator secrets for that gateway.
- Treat credentials that can call `/v1/chat/completions`, `/v1/responses`, or `/api/channels/*` as full-access operator secrets for that gateway.
- On the OpenAI-compatible HTTP surface, shared-secret bearer auth restores the full default operator scopes and owner semantics for agent turns; narrower `x-openclaw-scopes` values do not reduce that shared-secret path.
- Per-request scope semantics on HTTP only apply when the request comes from an identity-bearing mode such as trusted proxy auth or `gateway.auth.mode="none"` on a private ingress.
- `/tools/invoke` is stricter: shared-secret bearer auth is rejected there, and the endpoint only runs when the HTTP request carries a trusted operator identity plus declared scopes.
- Do not share these credentials with untrusted callers; prefer separate gateways per trust boundary.
**Trust assumption:** tokenless Serve auth assumes the gateway host is trusted.

View File

@@ -8,7 +8,7 @@ title: "Tools Invoke API"
# Tools Invoke (HTTP)
OpenClaws Gateway exposes a simple HTTP endpoint for invoking a single tool directly. It is always enabled and uses Gateway auth plus tool policy, but callers that pass Gateway bearer auth are treated as trusted operators for that gateway.
OpenClaws Gateway exposes a simple HTTP endpoint for invoking a single tool directly. It is always enabled and uses Gateway auth plus tool policy, but unlike the OpenAI-compatible `/v1/*` surface, shared-secret bearer auth is not enough to use it.
- `POST /tools/invoke`
- Same port as the Gateway (WS + HTTP multiplex): `http://<gateway-host>:<port>/tools/invoke`
@@ -26,7 +26,8 @@ Notes:
- When `gateway.auth.mode="token"`, use `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`).
- When `gateway.auth.mode="password"`, use `gateway.auth.password` (or `OPENCLAW_GATEWAY_PASSWORD`).
- If `gateway.auth.rateLimit` is configured and too many auth failures occur, the endpoint returns `429` with `Retry-After`.
- Treat this credential as a full-access operator secret for that gateway. It is not a scoped API token for a narrower `/tools/invoke` role.
- Shared-secret bearer auth (`gateway.auth.mode="token"` or `"password"`) is rejected here with `403`.
- To use `/tools/invoke`, the request must come from an HTTP mode that carries a trusted operator identity and declared scopes (for example trusted proxy auth or `gateway.auth.mode="none"` on a private ingress).
## Request body
@@ -62,7 +63,7 @@ If a tool is not allowed by policy, the endpoint returns **404**.
Important boundary notes:
- `POST /tools/invoke` is in the same trusted-operator bucket as other Gateway HTTP APIs such as `/v1/chat/completions`, `/v1/responses`, and `/api/channels/*`.
- `POST /tools/invoke` is intentionally stricter than `/v1/chat/completions` and `/v1/responses`: shared-secret bearer auth does not unlock direct tool invocation over HTTP.
- Exec approvals are operator guardrails, not a separate authorization boundary for this HTTP endpoint. If a tool is reachable here via Gateway auth + tool policy, `/tools/invoke` does not add an extra per-call approval prompt.
- Do not share Gateway bearer credentials with untrusted callers. If you need separation across trust boundaries, run separate gateways (and ideally separate OS users/hosts).
@@ -116,11 +117,15 @@ To help group policies resolve context, you can optionally set:
```bash
curl -sS http://127.0.0.1:18789/tools/invoke \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-H 'x-openclaw-scopes: operator.write' \
-d '{
"tool": "sessions_list",
"action": "json",
"args": {}
}'
```
Use this example only on a private ingress with a trusted identity-bearing HTTP
mode (for example trusted proxy auth or `gateway.auth.mode="none"`).
Shared-secret bearer auth does not work on `/tools/invoke`.

View File

@@ -167,7 +167,7 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
expect(json.error?.type).toBe("invalid_request_error");
});
it("rejects operator scopes that lack write access", async () => {
it("ignores narrower declared scopes for shared-secret bearer auth", async () => {
const res = await postEmbeddings(
{
model: "openclaw/default",
@@ -175,17 +175,14 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
},
{ "x-openclaw-scopes": "operator.read" },
);
expect(res.status).toBe(403);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
ok: false,
error: {
type: "forbidden",
message: "missing scope: operator.write",
},
object: "list",
data: [{ object: "embedding", embedding: [0.1, 0.2] }],
});
});
it("rejects requests with no declared operator scopes", async () => {
it("allows requests with an empty declared scopes header", async () => {
const res = await postEmbeddings(
{
model: "openclaw/default",
@@ -193,17 +190,14 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
},
{ "x-openclaw-scopes": "" },
);
expect(res.status).toBe(403);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
ok: false,
error: {
type: "forbidden",
message: "missing scope: operator.write",
},
object: "list",
data: [{ object: "embedding", embedding: [0.1, 0.2] }],
});
});
it("rejects requests when the operator scopes header is missing", async () => {
it("allows requests when the operator scopes header is missing", async () => {
const res = await fetch(`http://127.0.0.1:${enabledPort}/v1/embeddings`, {
method: "POST",
headers: {
@@ -215,13 +209,10 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
input: "hello",
}),
});
expect(res.status).toBe(403);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
ok: false,
error: {
type: "forbidden",
message: "missing scope: operator.write",
},
object: "list",
data: [{ object: "embedding", embedding: [0.1, 0.2] }],
});
});

View File

@@ -19,6 +19,7 @@ import {
getHeader,
resolveAgentIdForRequest,
resolveAgentIdFromModel,
resolveOpenAiCompatibleHttpOperatorScopes,
} from "./http-utils.js";
type OpenAiEmbeddingsHttpOptions = {
@@ -210,6 +211,7 @@ export async function handleOpenAiEmbeddingsHttpRequest(
const handled = await handleGatewayPostJsonEndpoint(req, res, {
pathname: "/v1/embeddings",
requiredOperatorMethod: "chat.send",
resolveOperatorScopes: resolveOpenAiCompatibleHttpOperatorScopes,
auth: opts.auth,
trustedProxies: opts.trustedProxies,
allowRealIpFallback: opts.allowRealIpFallback,

View File

@@ -138,4 +138,42 @@ describe("handleGatewayPostJsonEndpoint", () => {
);
expect(vi.mocked(readJsonBodyOrError)).not.toHaveBeenCalled();
});
it("uses a custom operator scope resolver when provided", async () => {
vi.mocked(authorizeGatewayHttpRequestOrReply).mockResolvedValue({
authMethod: "token",
trustDeclaredOperatorScopes: false,
});
vi.mocked(authorizeOperatorScopesForMethod).mockReturnValue({ allowed: true });
vi.mocked(readJsonBodyOrError).mockResolvedValue({ ok: true });
const resolveOperatorScopes = vi.fn(() => ["operator.admin", "operator.write"]);
const result = await handleGatewayPostJsonEndpoint(
{
url: "/v1/ok",
method: "POST",
headers: { host: "localhost" },
} as unknown as IncomingMessage,
{} as unknown as ServerResponse,
{
pathname: "/v1/ok",
auth: {} as unknown as ResolvedGatewayAuth,
maxBodyBytes: 123,
requiredOperatorMethod: "chat.send",
resolveOperatorScopes,
},
);
expect(resolveOperatorScopes).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
authMethod: "token",
trustDeclaredOperatorScopes: false,
}),
);
expect(result).toEqual({
body: { ok: true },
requestAuth: { authMethod: "token", trustDeclaredOperatorScopes: false },
});
});
});

View File

@@ -20,6 +20,10 @@ export async function handleGatewayPostJsonEndpoint(
allowRealIpFallback?: boolean;
rateLimiter?: AuthRateLimiter;
requiredOperatorMethod?: "chat.send" | (string & Record<never, never>);
resolveOperatorScopes?: (
req: IncomingMessage,
requestAuth: AuthorizedGatewayHttpRequest,
) => string[];
},
): Promise<false | { body: unknown; requestAuth: AuthorizedGatewayHttpRequest } | undefined> {
const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`);
@@ -45,7 +49,9 @@ export async function handleGatewayPostJsonEndpoint(
}
if (opts.requiredOperatorMethod) {
const requestedScopes = resolveTrustedHttpOperatorScopes(req, requestAuth);
const requestedScopes =
opts.resolveOperatorScopes?.(req, requestAuth) ??
resolveTrustedHttpOperatorScopes(req, requestAuth);
const scopeAuth = authorizeOperatorScopesForMethod(
opts.requiredOperatorMethod,
requestedScopes,

View File

@@ -1,6 +1,8 @@
import type { IncomingMessage } from "node:http";
import { describe, expect, it } from "vitest";
import {
resolveOpenAiCompatibleHttpOperatorScopes,
resolveOpenAiCompatibleHttpSenderIsOwner,
resolveGatewayRequestContext,
resolveHttpSenderIsOwner,
resolveTrustedHttpOperatorScopes,
@@ -122,3 +124,63 @@ describe("resolveHttpSenderIsOwner", () => {
).toBe(false);
});
});
describe("resolveOpenAiCompatibleHttpOperatorScopes", () => {
it("restores default operator scopes for shared-secret bearer auth", () => {
const scopes = resolveOpenAiCompatibleHttpOperatorScopes(
createReq({
authorization: "Bearer secret",
"x-openclaw-scopes": "operator.approvals",
}),
{ authMethod: "token", trustDeclaredOperatorScopes: false },
);
expect(scopes).toEqual([
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
]);
});
it("keeps declared scopes for trusted HTTP identity-bearing requests", () => {
const scopes = resolveOpenAiCompatibleHttpOperatorScopes(
createReq({
"x-openclaw-scopes": "operator.write",
}),
{ authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true },
);
expect(scopes).toEqual(["operator.write"]);
});
});
describe("resolveOpenAiCompatibleHttpSenderIsOwner", () => {
it("treats shared-secret bearer auth as owner on the compat surface", () => {
expect(
resolveOpenAiCompatibleHttpSenderIsOwner(
createReq({
authorization: "Bearer secret",
"x-openclaw-scopes": "operator.approvals",
}),
{ authMethod: "token", trustDeclaredOperatorScopes: false },
),
).toBe(true);
});
it("still requires operator.admin for trusted scope-bearing requests", () => {
expect(
resolveOpenAiCompatibleHttpSenderIsOwner(
createReq({ "x-openclaw-scopes": "operator.write" }),
{ authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true },
),
).toBe(false);
expect(
resolveOpenAiCompatibleHttpSenderIsOwner(
createReq({ "x-openclaw-scopes": "operator.admin" }),
{ authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true },
),
).toBe(true);
});
});

View File

@@ -17,7 +17,7 @@ import {
type ResolvedGatewayAuth,
} from "./auth.js";
import { sendGatewayAuthFailure } from "./http-common.js";
import { ADMIN_SCOPE } from "./method-scopes.js";
import { ADMIN_SCOPE, CLI_DEFAULT_OPERATOR_SCOPES } from "./method-scopes.js";
import { loadGatewayModelCatalog } from "./server-model-catalog.js";
export const OPENCLAW_MODEL_ID = "openclaw";
@@ -93,6 +93,10 @@ export async function authorizeGatewayHttpRequestOrReply(params: {
}
return {
authMethod: authResult.method,
// Shared-secret bearer auth proves possession of the gateway secret, but it
// does not prove a narrower per-request operator identity. HTTP endpoints
// must opt in explicitly if they want to treat that shared-secret path as a
// full trusted-operator surface.
trustDeclaredOperatorScopes: !usesSharedSecretGatewayMethod(authResult.method),
};
}
@@ -126,6 +130,19 @@ export function resolveTrustedHttpOperatorScopes(
.filter((scope) => scope.length > 0);
}
export function resolveOpenAiCompatibleHttpOperatorScopes(
req: IncomingMessage,
requestAuth: AuthorizedGatewayHttpRequest,
): string[] {
if (usesSharedSecretGatewayMethod(requestAuth.authMethod)) {
// OpenAI-compatible HTTP bearer auth is documented as a trusted-operator
// surface. Shared-secret auth does not carry a narrower per-request scope
// identity, so restore the normal operator defaults for this surface.
return [...CLI_DEFAULT_OPERATOR_SCOPES];
}
return resolveTrustedHttpOperatorScopes(req, requestAuth);
}
export function resolveHttpSenderIsOwner(
req: IncomingMessage,
authOrRequest?:
@@ -135,6 +152,20 @@ export function resolveHttpSenderIsOwner(
return resolveTrustedHttpOperatorScopes(req, authOrRequest).includes(ADMIN_SCOPE);
}
export function resolveOpenAiCompatibleHttpSenderIsOwner(
req: IncomingMessage,
requestAuth: AuthorizedGatewayHttpRequest,
): boolean {
if (usesSharedSecretGatewayMethod(requestAuth.authMethod)) {
// The OpenAI-compatible HTTP surface treats shared-secret bearer auth as
// trusted operator access for the whole gateway. There is no separate owner
// authentication primitive on that path, so owner-only tools remain
// available to those compat requests.
return true;
}
return resolveHttpSenderIsOwner(req, requestAuth);
}
export function resolveAgentIdFromHeader(req: IncomingMessage): string | undefined {
const raw =
getHeader(req, "x-openclaw-agent-id")?.trim() ||

View File

@@ -28,6 +28,15 @@ async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?:
});
}
async function startTokenServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) {
return await startGatewayServer(port, {
host: "127.0.0.1",
auth: { mode: "token", token: "secret" },
controlUiEnabled: false,
openAiChatCompletionsEnabled: opts?.openAiChatCompletionsEnabled ?? false,
});
}
async function getModels(pathname: string, headers?: Record<string, string>) {
return await fetch(`http://127.0.0.1:${enabledPort}${pathname}`, {
headers: {
@@ -120,4 +129,23 @@ describe("OpenAI-compatible models HTTP API (e2e)", () => {
await server.close({ reason: "models disabled test done" });
}
});
it("treats shared-secret bearer auth as full compat operator access", async () => {
const port = await getFreePort();
const server = await startTokenServer(port, { openAiChatCompletionsEnabled: true });
try {
const res = await fetch(`http://127.0.0.1:${port}/v1/models`, {
headers: {
authorization: "Bearer secret",
"x-openclaw-scopes": "operator.approvals",
},
});
expect(res.status).toBe(200);
const json = (await res.json()) as { object?: string; data?: Array<{ id?: string }> };
expect(json.object).toBe("list");
expect(json.data?.map((entry) => entry.id)).toContain("openclaw/default");
} finally {
await server.close({ reason: "models token auth compat test done" });
}
});
});

View File

@@ -10,7 +10,7 @@ import {
authorizeGatewayHttpRequestOrReply,
type AuthorizedGatewayHttpRequest,
resolveAgentIdFromModel,
resolveTrustedHttpOperatorScopes,
resolveOpenAiCompatibleHttpOperatorScopes,
} from "./http-utils.js";
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
@@ -89,7 +89,7 @@ export async function handleOpenAiModelsHttpRequest(
return true;
}
const requestedScopes = resolveTrustedHttpOperatorScopes(req, requestAuth);
const requestedScopes = resolveOpenAiCompatibleHttpOperatorScopes(req, requestAuth);
const scopeAuth = authorizeOperatorScopesForMethod("models.list", requestedScopes);
if (!scopeAuth.allowed) {
sendJson(res, 403, {

View File

@@ -47,6 +47,15 @@ async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?:
});
}
async function startTokenServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) {
return await startGatewayServer(port, {
host: "127.0.0.1",
auth: { mode: "token", token: "secret" },
controlUiEnabled: false,
openAiChatCompletionsEnabled: opts?.openAiChatCompletionsEnabled ?? true,
});
}
async function writeGatewayConfig(config: Record<string, unknown>) {
const configPath = process.env.OPENCLAW_CONFIG_PATH;
if (!configPath) {
@@ -840,4 +849,35 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
// shared server
}
});
it("treats shared-secret bearer callers as owner operators", async () => {
const port = await getFreePort();
const server = await startTokenServer(port);
try {
agentCommand.mockClear();
agentCommand.mockResolvedValueOnce({ payloads: [{ text: "hello" }] } as never);
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: "POST",
headers: {
authorization: "Bearer secret",
"content-type": "application/json",
"x-openclaw-scopes": "operator.approvals",
},
body: JSON.stringify({
model: "openclaw",
messages: [{ role: "user", content: "hi" }],
}),
});
expect(res.status).toBe(200);
const firstCall = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as
| { senderIsOwner?: boolean }
| undefined;
expect(firstCall?.senderIsOwner).toBe(true);
await res.text();
} finally {
await server.close({ reason: "openai token auth owner test done" });
}
});
});

View File

@@ -27,7 +27,12 @@ import type { AuthRateLimiter } from "./auth-rate-limit.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import { sendJson, setSseHeaders, writeDone } from "./http-common.js";
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
import { resolveGatewayRequestContext, resolveOpenAiCompatModelOverride } from "./http-utils.js";
import {
resolveGatewayRequestContext,
resolveOpenAiCompatModelOverride,
resolveOpenAiCompatibleHttpOperatorScopes,
resolveOpenAiCompatibleHttpSenderIsOwner,
} from "./http-utils.js";
import { normalizeInputHostnameAllowlist } from "./input-allowlist.js";
type OpenAiHttpOptions = {
@@ -106,6 +111,7 @@ function buildAgentCommandInput(params: {
sessionKey: string;
runId: string;
messageChannel: string;
senderIsOwner: boolean;
}) {
return {
message: params.prompt.message,
@@ -117,8 +123,7 @@ function buildAgentCommandInput(params: {
deliver: false as const,
messageChannel: params.messageChannel,
bestEffortDeliver: false as const,
// OpenAI-compatible HTTP ingress is external input and must not inherit owner-only tools.
senderIsOwner: false as const,
senderIsOwner: params.senderIsOwner,
allowModelOverride: true as const,
};
}
@@ -417,6 +422,9 @@ export async function handleOpenAiHttpRequest(
const handled = await handleGatewayPostJsonEndpoint(req, res, {
pathname: "/v1/chat/completions",
requiredOperatorMethod: "chat.send",
// Compat HTTP uses a different scope model from generic HTTP helpers:
// shared-secret bearer auth is treated as full operator access here.
resolveOperatorScopes: resolveOpenAiCompatibleHttpOperatorScopes,
auth: opts.auth,
trustedProxies: opts.trustedProxies,
allowRealIpFallback: opts.allowRealIpFallback,
@@ -429,6 +437,9 @@ export async function handleOpenAiHttpRequest(
if (!handled) {
return true;
}
// On the compat surface, shared-secret bearer auth is also treated as an
// owner sender so owner-only tool policy matches the documented contract.
const senderIsOwner = resolveOpenAiCompatibleHttpSenderIsOwner(req, handled.requestAuth);
const payload = coerceRequest(handled.body);
const stream = Boolean(payload.stream);
@@ -492,6 +503,7 @@ export async function handleOpenAiHttpRequest(
sessionKey,
runId,
messageChannel,
senderIsOwner,
});
if (!stream) {

View File

@@ -56,6 +56,21 @@ async function startServer(port: number, opts?: { openResponsesEnabled?: boolean
);
}
async function startTokenServer(port: number, opts?: { openResponsesEnabled?: boolean }) {
const { startGatewayServer } = await import("./server.js");
const serverOpts = {
host: "127.0.0.1",
auth: { mode: "token" as const, token: "secret" },
controlUiEnabled: false,
} as const;
return await startGatewayServer(
port,
opts?.openResponsesEnabled === undefined
? { ...serverOpts, openResponsesEnabled: true }
: { ...serverOpts, openResponsesEnabled: opts.openResponsesEnabled },
);
}
async function writeGatewayConfig(config: Record<string, unknown>) {
const configPath = process.env.OPENCLAW_CONFIG_PATH;
if (!configPath) {
@@ -755,6 +770,37 @@ describe("OpenResponses HTTP API (e2e)", () => {
expect(streamingEvents.some((event) => event.event === "response.completed")).toBe(true);
});
it("treats shared-secret bearer callers as owner operators", async () => {
const port = await getFreePort();
const server = await startTokenServer(port);
try {
agentCommand.mockClear();
agentCommand.mockResolvedValueOnce({ payloads: [{ text: "hello" }] } as never);
const res = await fetch(`http://127.0.0.1:${port}/v1/responses`, {
method: "POST",
headers: {
authorization: "Bearer secret",
"content-type": "application/json",
"x-openclaw-scopes": "operator.approvals",
},
body: JSON.stringify({
model: "openclaw",
input: "hi",
}),
});
expect(res.status).toBe(200);
const firstCall = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as
| { senderIsOwner?: boolean }
| undefined;
expect(firstCall?.senderIsOwner).toBe(true);
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "openresponses token auth owner test done" });
}
});
it("preserves assistant text alongside non-stream function_call output", async () => {
const port = enabledPort;
agentCommand.mockClear();

View File

@@ -41,6 +41,8 @@ import {
resolveAgentIdForRequest,
resolveGatewayRequestContext,
resolveOpenAiCompatModelOverride,
resolveOpenAiCompatibleHttpOperatorScopes,
resolveOpenAiCompatibleHttpSenderIsOwner,
} from "./http-utils.js";
import { normalizeInputHostnameAllowlist } from "./input-allowlist.js";
import {
@@ -462,6 +464,9 @@ export async function handleOpenResponsesHttpRequest(
const handled = await handleGatewayPostJsonEndpoint(req, res, {
pathname: "/v1/responses",
requiredOperatorMethod: "chat.send",
// Compat HTTP uses a different scope model from generic HTTP helpers:
// shared-secret bearer auth is treated as full operator access here.
resolveOperatorScopes: resolveOpenAiCompatibleHttpOperatorScopes,
auth: opts.auth,
trustedProxies: opts.trustedProxies,
allowRealIpFallback: opts.allowRealIpFallback,
@@ -474,6 +479,9 @@ export async function handleOpenResponsesHttpRequest(
if (!handled) {
return true;
}
// On the compat surface, shared-secret bearer auth is also treated as an
// owner sender so owner-only tool policy matches the documented contract.
const senderIsOwner = resolveOpenAiCompatibleHttpSenderIsOwner(req, handled.requestAuth);
// Validate request body with Zod
const parseResult = CreateResponseBodySchema.safeParse(handled.body);
@@ -704,7 +712,7 @@ export async function handleOpenResponsesHttpRequest(
sessionKey,
runId: responseId,
messageChannel,
senderIsOwner: false,
senderIsOwner,
deps,
});
@@ -957,7 +965,7 @@ export async function handleOpenResponsesHttpRequest(
sessionKey,
runId: responseId,
messageChannel,
senderIsOwner: false,
senderIsOwner,
deps,
});

View File

@@ -9,8 +9,8 @@ import {
installGatewayTestHooks({ scope: "suite" });
describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => {
test("operator.approvals is denied by chat.send and /v1/chat/completions without operator.write", async () => {
describe("gateway OpenAI-compatible HTTP shared-secret auth", () => {
test("operator.approvals stays denied on WS chat.send but compat chat HTTP restores full operator defaults", async () => {
const started = await startServerWithClient("secret", {
openAiChatCompletionsEnabled: true,
});
@@ -43,18 +43,18 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => {
}),
});
expect(httpRes.status).toBe(403);
expect(httpRes.status).toBe(200);
const body = (await httpRes.json()) as {
error?: { type?: string; message?: string };
id?: string;
object?: string;
};
expect(body.error?.type).toBe("forbidden");
expect(body.error?.message).toBe("missing scope: operator.write");
expect(agentCommand).toHaveBeenCalledTimes(0);
expect(body.object).toBe("chat.completion");
expect(agentCommand).toHaveBeenCalledTimes(1);
const firstCall = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as
| { senderIsOwner?: boolean }
| undefined;
expect(firstCall?.senderIsOwner).toBe(true);
// Requests without x-openclaw-scopes header now receive default
// CLI_DEFAULT_OPERATOR_SCOPES (which include operator.write), so they
// are authorised. The explicit-header test above still proves that a
// caller who *declares* only operator.approvals is correctly rejected.
agentCommand.mockClear();
agentCommand.mockResolvedValueOnce({ payloads: [{ text: "hello" }] } as never);
const missingHeaderRes = await fetch(`http://127.0.0.1:${started.port}/v1/chat/completions`, {
@@ -69,13 +69,8 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => {
}),
});
expect(missingHeaderRes.status).toBe(403);
const missingHeaderBody = (await missingHeaderRes.json()) as {
error?: { type?: string; message?: string };
};
expect(missingHeaderBody.error?.type).toBe("forbidden");
expect(missingHeaderBody.error?.message).toBe("missing scope: operator.write");
expect(agentCommand).toHaveBeenCalledTimes(0);
expect(missingHeaderRes.status).toBe(200);
expect(agentCommand).toHaveBeenCalledTimes(1);
} finally {
started.ws.close();
await started.server.close();
@@ -83,7 +78,7 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => {
}
});
test("bearer auth cannot self-assert operator.write for /v1/chat/completions", async () => {
test("shared-secret bearer auth ignores narrower declared write scopes for /v1/chat/completions", async () => {
const started = await startServerWithClient("secret", {
openAiChatCompletionsEnabled: true,
});
@@ -105,13 +100,8 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => {
}),
});
expect(httpRes.status).toBe(403);
const body = (await httpRes.json()) as {
error?: { type?: string; message?: string };
};
expect(body.error?.type).toBe("forbidden");
expect(body.error?.message).toBe("missing scope: operator.write");
expect(agentCommand).toHaveBeenCalledTimes(0);
expect(httpRes.status).toBe(200);
expect(agentCommand).toHaveBeenCalledTimes(1);
} finally {
started.ws.close();
await started.server.close();
@@ -119,7 +109,7 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => {
}
});
test("operator.approvals is denied by chat.send and /v1/responses without operator.write", async () => {
test("operator.approvals stays denied on WS chat.send but compat responses HTTP restores full operator defaults", async () => {
const started = await startServerWithClient("secret", {
openResponsesEnabled: true,
});
@@ -153,13 +143,16 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => {
}),
});
expect(httpRes.status).toBe(403);
expect(httpRes.status).toBe(200);
const body = (await httpRes.json()) as {
error?: { type?: string; message?: string };
object?: string;
};
expect(body.error?.type).toBe("forbidden");
expect(body.error?.message).toBe("missing scope: operator.write");
expect(agentCommand).toHaveBeenCalledTimes(0);
expect(body.object).toBe("response");
expect(agentCommand).toHaveBeenCalledTimes(1);
const firstCall = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as
| { senderIsOwner?: boolean }
| undefined;
expect(firstCall?.senderIsOwner).toBe(true);
} finally {
started.ws.close();
await started.server.close();
@@ -167,7 +160,7 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => {
}
});
test("bearer auth cannot self-assert operator.write for /v1/responses", async () => {
test("shared-secret bearer auth ignores narrower declared write scopes for /v1/responses", async () => {
const started = await startServerWithClient("secret", {
openResponsesEnabled: true,
});
@@ -190,13 +183,8 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => {
}),
});
expect(httpRes.status).toBe(403);
const body = (await httpRes.json()) as {
error?: { type?: string; message?: string };
};
expect(body.error?.type).toBe("forbidden");
expect(body.error?.message).toBe("missing scope: operator.write");
expect(agentCommand).toHaveBeenCalledTimes(0);
expect(httpRes.status).toBe(200);
expect(agentCommand).toHaveBeenCalledTimes(1);
} finally {
started.ws.close();
await started.server.close();