diff --git a/src/gateway/http-endpoint-helpers.test.ts b/src/gateway/http-endpoint-helpers.test.ts index b359c3a5689..a6cafb5b0c9 100644 --- a/src/gateway/http-endpoint-helpers.test.ts +++ b/src/gateway/http-endpoint-helpers.test.ts @@ -6,18 +6,28 @@ import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js"; vi.mock("./http-auth-helpers.js", () => { return { authorizeGatewayBearerRequestOrReply: vi.fn(), + resolveGatewayRequestedOperatorScopes: vi.fn(), }; }); vi.mock("./http-common.js", () => { return { readJsonBodyOrError: vi.fn(), + sendJson: vi.fn(), sendMethodNotAllowed: vi.fn(), }; }); +vi.mock("./method-scopes.js", () => { + return { + authorizeOperatorScopesForMethod: vi.fn(), + }; +}); + const { authorizeGatewayBearerRequestOrReply } = await import("./http-auth-helpers.js"); -const { readJsonBodyOrError, sendMethodNotAllowed } = await import("./http-common.js"); +const { resolveGatewayRequestedOperatorScopes } = await import("./http-auth-helpers.js"); +const { readJsonBodyOrError, sendJson, sendMethodNotAllowed } = await import("./http-common.js"); +const { authorizeOperatorScopesForMethod } = await import("./method-scopes.js"); describe("handleGatewayPostJsonEndpoint", () => { it("returns false when path does not match", async () => { @@ -77,4 +87,48 @@ describe("handleGatewayPostJsonEndpoint", () => { ); expect(result).toEqual({ body: { hello: "world" } }); }); + + it("returns undefined and replies when required operator scope is missing", async () => { + vi.mocked(authorizeGatewayBearerRequestOrReply).mockResolvedValue(true); + vi.mocked(resolveGatewayRequestedOperatorScopes).mockReturnValue(["operator.approvals"]); + vi.mocked(authorizeOperatorScopesForMethod).mockReturnValue({ + allowed: false, + missingScope: "operator.write", + }); + const mockedSendJson = vi.mocked(sendJson); + mockedSendJson.mockClear(); + vi.mocked(readJsonBodyOrError).mockClear(); + + 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", + }, + ); + + expect(result).toBeUndefined(); + expect(vi.mocked(authorizeOperatorScopesForMethod)).toHaveBeenCalledWith("chat.send", [ + "operator.approvals", + ]); + expect(mockedSendJson).toHaveBeenCalledWith( + expect.anything(), + 403, + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + type: "forbidden", + message: "missing scope: operator.write", + }), + }), + ); + expect(vi.mocked(readJsonBodyOrError)).not.toHaveBeenCalled(); + }); }); diff --git a/src/gateway/http-endpoint-helpers.ts b/src/gateway/http-endpoint-helpers.ts index 2ea005956f4..650ac42ac7b 100644 --- a/src/gateway/http-endpoint-helpers.ts +++ b/src/gateway/http-endpoint-helpers.ts @@ -1,8 +1,12 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "./auth.js"; -import { authorizeGatewayBearerRequestOrReply } from "./http-auth-helpers.js"; -import { readJsonBodyOrError, sendMethodNotAllowed } from "./http-common.js"; +import { + authorizeGatewayBearerRequestOrReply, + resolveGatewayRequestedOperatorScopes, +} from "./http-auth-helpers.js"; +import { readJsonBodyOrError, sendJson, sendMethodNotAllowed } from "./http-common.js"; +import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; export async function handleGatewayPostJsonEndpoint( req: IncomingMessage, @@ -14,6 +18,7 @@ export async function handleGatewayPostJsonEndpoint( trustedProxies?: string[]; allowRealIpFallback?: boolean; rateLimiter?: AuthRateLimiter; + requiredOperatorMethod?: "chat.send" | (string & Record); }, ): Promise { const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`); @@ -38,6 +43,24 @@ export async function handleGatewayPostJsonEndpoint( return undefined; } + if (opts.requiredOperatorMethod) { + const requestedScopes = resolveGatewayRequestedOperatorScopes(req); + const scopeAuth = authorizeOperatorScopesForMethod( + opts.requiredOperatorMethod, + requestedScopes, + ); + if (!scopeAuth.allowed) { + sendJson(res, 403, { + ok: false, + error: { + type: "forbidden", + message: `missing scope: ${scopeAuth.missingScope}`, + }, + }); + return undefined; + } + } + const body = await readJsonBodyOrError(req, res, opts.maxBodyBytes); if (body === undefined) { return undefined; diff --git a/src/gateway/openai-http.message-channel.test.ts b/src/gateway/openai-http.message-channel.test.ts index 3c602cbac18..caee85778eb 100644 --- a/src/gateway/openai-http.message-channel.test.ts +++ b/src/gateway/openai-http.message-channel.test.ts @@ -20,6 +20,7 @@ async function runOpenAiMessageChannelRequest(params?: { messageChannelHeader?: const headers: Record = { "content-type": "application/json", authorization: "Bearer secret", + "x-openclaw-scopes": "operator.write", }; if (params?.messageChannelHeader) { headers["x-openclaw-message-channel"] = params.messageChannelHeader; diff --git a/src/gateway/openai-http.test.ts b/src/gateway/openai-http.test.ts index 575527b6cb8..5480135a9cf 100644 --- a/src/gateway/openai-http.test.ts +++ b/src/gateway/openai-http.test.ts @@ -62,6 +62,7 @@ async function postChatCompletions(port: number, body: unknown, headers?: Record headers: { "content-type": "application/json", authorization: "Bearer secret", + "x-openclaw-scopes": "operator.write", ...headers, }, body: JSON.stringify(body), diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index dd8fecb9ebf..61e6c958765 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -416,6 +416,7 @@ export async function handleOpenAiHttpRequest( const limits = resolveOpenAiChatCompletionsLimits(opts.config); const handled = await handleGatewayPostJsonEndpoint(req, res, { pathname: "/v1/chat/completions", + requiredOperatorMethod: "chat.send", auth: opts.auth, trustedProxies: opts.trustedProxies, allowRealIpFallback: opts.allowRealIpFallback, diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index 9b6f5405311..c507b18dad5 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -71,6 +71,7 @@ async function postResponses(port: number, body: unknown, headers?: Record { + test("operator.approvals is denied by chat.send and /v1/chat/completions without operator.write", async () => { + const started = await startServerWithClient("secret", { + openAiChatCompletionsEnabled: true, + }); + + try { + const connect = await connectReq(started.ws, { + token: "secret", + scopes: ["operator.approvals"], + }); + expect(connect.ok).toBe(true); + + const wsSend = await rpcReq(started.ws, "chat.send", { + sessionKey: "main", + message: "hi", + }); + expect(wsSend.ok).toBe(false); + expect(wsSend.error?.message).toBe("missing scope: operator.write"); + + agentCommand.mockClear(); + const httpRes = await fetch(`http://127.0.0.1:${started.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(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); + + agentCommand.mockClear(); + const missingHeaderRes = await fetch(`http://127.0.0.1:${started.port}/v1/chat/completions`, { + method: "POST", + headers: { + authorization: "Bearer secret", + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }), + }); + + 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); + } finally { + started.ws.close(); + await started.server.close(); + started.envSnapshot.restore(); + } + }); + + test("operator.write can still use /v1/chat/completions", async () => { + const started = await startServerWithClient("secret", { + openAiChatCompletionsEnabled: true, + }); + + try { + agentCommand.mockClear(); + agentCommand.mockResolvedValueOnce({ payloads: [{ text: "hello" }] } as never); + + const httpRes = await fetch(`http://127.0.0.1:${started.port}/v1/chat/completions`, { + method: "POST", + headers: { + authorization: "Bearer secret", + "content-type": "application/json", + "x-openclaw-scopes": "operator.write", + }, + body: JSON.stringify({ + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }), + }); + + expect(httpRes.status).toBe(200); + const body = (await httpRes.json()) as { + object?: string; + choices?: Array<{ message?: { content?: string } }>; + }; + expect(body.object).toBe("chat.completion"); + expect(body.choices?.[0]?.message?.content).toBe("hello"); + expect(agentCommand).toHaveBeenCalledTimes(1); + } finally { + started.ws.close(); + await started.server.close(); + started.envSnapshot.restore(); + } + }); + + test("operator.approvals is denied by chat.send and /v1/responses without operator.write", async () => { + const started = await startServerWithClient("secret", { + openResponsesEnabled: true, + }); + + try { + const connect = await connectReq(started.ws, { + token: "secret", + scopes: ["operator.approvals"], + }); + expect(connect.ok).toBe(true); + + const wsSend = await rpcReq(started.ws, "chat.send", { + sessionKey: "main", + message: "hi", + }); + expect(wsSend.ok).toBe(false); + expect(wsSend.error?.message).toBe("missing scope: operator.write"); + + agentCommand.mockClear(); + const httpRes = await fetch(`http://127.0.0.1:${started.port}/v1/responses`, { + method: "POST", + headers: { + authorization: "Bearer secret", + "content-type": "application/json", + "x-openclaw-scopes": "operator.approvals", + }, + body: JSON.stringify({ + stream: false, + model: "openclaw", + input: "hi", + }), + }); + + 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); + } finally { + started.ws.close(); + await started.server.close(); + started.envSnapshot.restore(); + } + }); + + test("operator.write can still use /v1/responses", async () => { + const started = await startServerWithClient("secret", { + openResponsesEnabled: true, + }); + + try { + agentCommand.mockClear(); + agentCommand.mockResolvedValueOnce({ payloads: [{ text: "hello" }] } as never); + + const httpRes = await fetch(`http://127.0.0.1:${started.port}/v1/responses`, { + method: "POST", + headers: { + authorization: "Bearer secret", + "content-type": "application/json", + "x-openclaw-scopes": "operator.write", + }, + body: JSON.stringify({ + stream: false, + model: "openclaw", + input: "hi", + }), + }); + + expect(httpRes.status).toBe(200); + const body = (await httpRes.json()) as { + object?: string; + status?: string; + output?: Array<{ + type?: string; + role?: string; + content?: Array<{ type?: string; text?: string }>; + }>; + }; + expect(body.object).toBe("response"); + expect(body.status).toBe("completed"); + expect(body.output?.[0]?.type).toBe("message"); + expect(body.output?.[0]?.role).toBe("assistant"); + expect(body.output?.[0]?.content?.[0]?.type).toBe("output_text"); + expect(body.output?.[0]?.content?.[0]?.text).toBe("hello"); + expect(agentCommand).toHaveBeenCalledTimes(1); + } finally { + started.ws.close(); + await started.server.close(); + started.envSnapshot.restore(); + } + }); +});