From da944115659c545db83e85862bce48d03203657d Mon Sep 17 00:00:00 2001 From: Alex Alaniz Date: Fri, 20 Mar 2026 22:34:20 -0400 Subject: [PATCH] Gateway: wire publicMode defaults and trust guards --- src/agents/agent-scope.public-mode.test.ts | 32 +++++++++++++ src/agents/agent-scope.ts | 8 +++- src/config/schema.labels.ts | 2 + src/config/types.agent-defaults.ts | 2 + src/config/zod-schema.agent-defaults.ts | 1 + src/gateway/http-utils.ts | 4 +- src/gateway/openai-http.image-budget.test.ts | 38 +++++++++++++++ src/gateway/openai-http.test.ts | 20 ++++---- src/gateway/openai-http.ts | 11 +++-- src/gateway/openresponses-http.header.test.ts | 46 +++++++++++++++++++ src/gateway/openresponses-http.test.ts | 20 ++++---- src/gateway/openresponses-http.ts | 12 +++-- 12 files changed, 172 insertions(+), 24 deletions(-) create mode 100644 src/agents/agent-scope.public-mode.test.ts diff --git a/src/agents/agent-scope.public-mode.test.ts b/src/agents/agent-scope.public-mode.test.ts new file mode 100644 index 00000000000..4575a713979 --- /dev/null +++ b/src/agents/agent-scope.public-mode.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { resolveAgentPublicMode } from "./agent-scope.js"; + +describe("resolveAgentPublicMode", () => { + it("inherits agents.defaults.publicMode when an agent has no explicit override", () => { + expect( + resolveAgentPublicMode( + { + agents: { + defaults: { publicMode: true }, + list: [{ id: "public-agent" }], + }, + }, + "public-agent", + ), + ).toBe(true); + }); + + it("lets an explicit per-agent false override a default true value", () => { + expect( + resolveAgentPublicMode( + { + agents: { + defaults: { publicMode: true }, + list: [{ id: "private-agent", publicMode: false }], + }, + }, + "private-agent", + ), + ).toBe(false); + }); +}); diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 1283e95eee3..cbffa0d7a5c 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -125,9 +125,15 @@ export function resolveAgentConfig( if (!entry) { return undefined; } + const defaultPublicMode = cfg.agents?.defaults?.publicMode; return { name: typeof entry.name === "string" ? entry.name : undefined, - publicMode: entry.publicMode === true ? true : undefined, + publicMode: + typeof entry.publicMode === "boolean" + ? entry.publicMode + : typeof defaultPublicMode === "boolean" + ? defaultPublicMode + : undefined, workspace: typeof entry.workspace === "string" ? entry.workspace : undefined, agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined, model: diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 854975b5a9c..9d1f5b8c7aa 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -65,7 +65,9 @@ export const FIELD_LABELS: Record = { "agents.list[].runtime.acp.cwd": "Agent ACP Working Directory", agents: "Agents", "agents.defaults": "Agent Defaults", + "agents.defaults.publicMode": "Agent Default Public Mode", "agents.list": "Agent List", + "agents.list[].publicMode": "Agent Public Mode", gateway: "Gateway", "gateway.port": "Gateway Port", "gateway.mode": "Gateway Mode", diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 604bf88bdcb..6e2200598a4 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -120,6 +120,8 @@ export type CliBackendConfig = { export type AgentDefaultsConfig = { /** Primary model and fallbacks (provider/model). Accepts string or {primary,fallbacks}. */ model?: AgentModelConfig; + /** Hide environment/runtime details from prompts for public-facing agents by default. */ + publicMode?: boolean; /** Optional image-capable model and fallbacks (provider/model). Accepts string or {primary,fallbacks}. */ imageModel?: AgentModelConfig; /** Optional image-generation model and fallbacks (provider/model). Accepts string or {primary,fallbacks}. */ diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 836a1fdae91..35da43e31dd 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -17,6 +17,7 @@ import { export const AgentDefaultsSchema = z .object({ model: AgentModelSchema.optional(), + publicMode: z.boolean().optional(), imageModel: AgentModelSchema.optional(), imageGenerationModel: AgentModelSchema.optional(), pdfModel: AgentModelSchema.optional(), diff --git a/src/gateway/http-utils.ts b/src/gateway/http-utils.ts index ca13f1fafad..ec62538223f 100644 --- a/src/gateway/http-utils.ts +++ b/src/gateway/http-utils.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import type { IncomingMessage } from "node:http"; import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; +import { isTrustedProxyAddress } from "./net.js"; export function getHeader(req: IncomingMessage, name: string): string | undefined { const raw = req.headers[name.toLowerCase()]; @@ -26,10 +27,11 @@ export function getBearerToken(req: IncomingMessage): string | undefined { export function resolveIngressSenderIsOwner(params: { req: IncomingMessage; publicMode?: boolean; + trustedProxies?: string[]; }): boolean { const raw = getHeader(params.req, "x-openclaw-sender-is-owner")?.trim().toLowerCase(); if (raw === "true") { - return true; + return isTrustedProxyAddress(params.req.socket?.remoteAddress, params.trustedProxies); } if (raw === "false") { return false; diff --git a/src/gateway/openai-http.image-budget.test.ts b/src/gateway/openai-http.image-budget.test.ts index 0810a49434f..54bfb69476f 100644 --- a/src/gateway/openai-http.image-budget.test.ts +++ b/src/gateway/openai-http.image-budget.test.ts @@ -94,4 +94,42 @@ describe("openai image budget accounting", () => { expect(command.senderIsOwner).toBe(false); }); + + it("ignores x-openclaw-sender-is-owner=true from an untrusted caller", () => { + const command = __testOnlyOpenAiHttp.buildAgentCommandInput({ + req: { + headers: { + "x-openclaw-sender-is-owner": "true", + }, + socket: { remoteAddress: "203.0.113.9" }, + } as never, + prompt: { message: "hi" }, + sessionKey: "agent:main:test", + runId: "run-1", + messageChannel: "webchat", + publicMode: true, + trustedProxies: ["127.0.0.1"], + }); + + expect(command.senderIsOwner).toBe(false); + }); + + it("honors x-openclaw-sender-is-owner=true from a trusted proxy", () => { + const command = __testOnlyOpenAiHttp.buildAgentCommandInput({ + req: { + headers: { + "x-openclaw-sender-is-owner": "true", + }, + socket: { remoteAddress: "127.0.0.1" }, + } as never, + prompt: { message: "hi" }, + sessionKey: "agent:main:test", + runId: "run-1", + messageChannel: "webchat", + publicMode: true, + trustedProxies: ["127.0.0.1"], + }); + + expect(command.senderIsOwner).toBe(true); + }); }); diff --git a/src/gateway/openai-http.test.ts b/src/gateway/openai-http.test.ts index 626c6ea6fd3..a32fc8b32be 100644 --- a/src/gateway/openai-http.test.ts +++ b/src/gateway/openai-http.test.ts @@ -638,7 +638,11 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { it("defaults public-mode agents to non-owner ingress unless explicitly trusted", async () => { testState.agentsConfig = { - list: [{ id: "beta", publicMode: true }], + defaults: { publicMode: true }, + list: [ + { id: "beta", publicMode: false }, + { id: "gamma", publicMode: true }, + ], }; agentCommand.mockClear(); @@ -662,7 +666,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { model: "openclaw", messages: [{ role: "user", content: "hi" }], }, - { "x-openclaw-agent-id": "beta" }, + { "x-openclaw-agent-id": "gamma" }, ); expect(publicRes.status).toBe(200); const publicOpts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as @@ -672,7 +676,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { await publicRes.text(); agentCommand.mockClear(); - const trustedPublicRes = await postChatCompletions( + const routedPublicRes = await postChatCompletions( enabledPort, { model: "openclaw", @@ -680,15 +684,15 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { }, { "x-openclaw-agent-id": "beta", - "x-openclaw-sender-is-owner": "true", + "x-openclaw-session-key": "agent:gamma:openai:routed-public", }, ); - expect(trustedPublicRes.status).toBe(200); - const trustedPublicOpts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as + expect(routedPublicRes.status).toBe(200); + const routedPublicOpts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as | { senderIsOwner?: boolean } | undefined; - expect(trustedPublicOpts?.senderIsOwner).toBe(true); - await trustedPublicRes.text(); + expect(routedPublicOpts?.senderIsOwner).toBe(false); + await routedPublicRes.text(); }); it("streams SSE chunks when stream=true", async () => { diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index ba2caced00b..5cde9a810c0 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; -import { resolveAgentPublicMode } from "../agents/agent-scope.js"; +import { resolveAgentPublicMode, resolveSessionAgentId } from "../agents/agent-scope.js"; import type { ImageContent } from "../agents/command/types.js"; import { createDefaultDeps } from "../cli/deps.js"; import { agentCommandFromIngress } from "../commands/agent.js"; @@ -110,10 +110,12 @@ function buildAgentCommandInput(params: { runId: string; messageChannel: string; publicMode?: boolean; + trustedProxies?: string[]; }) { const senderIsOwner = resolveIngressSenderIsOwner({ req: params.req, publicMode: params.publicMode, + trustedProxies: params.trustedProxies, }); return { message: params.prompt.message, @@ -441,7 +443,7 @@ export async function handleOpenAiHttpRequest( const model = typeof payload.model === "string" ? payload.model : "openclaw"; const user = typeof payload.user === "string" ? payload.user : undefined; - const { agentId, sessionKey, messageChannel } = resolveGatewayRequestContext({ + const { sessionKey, messageChannel } = resolveGatewayRequestContext({ req, model, user, @@ -449,7 +451,9 @@ export async function handleOpenAiHttpRequest( defaultMessageChannel: "webchat", useMessageChannelHeader: true, }); - const publicMode = resolveAgentPublicMode(opts.runtimeConfig ?? {}, agentId); + const runtimeConfig = opts.runtimeConfig ?? {}; + const effectiveAgentId = resolveSessionAgentId({ sessionKey, config: runtimeConfig }); + const publicMode = resolveAgentPublicMode(runtimeConfig, effectiveAgentId); const activeTurnContext = resolveActiveTurnContext(payload.messages); const prompt = buildAgentPrompt(payload.messages, activeTurnContext.activeUserMessageIndex); let images: ImageContent[] = []; @@ -489,6 +493,7 @@ export async function handleOpenAiHttpRequest( runId, messageChannel, publicMode, + trustedProxies: opts.trustedProxies, }); if (!stream) { diff --git a/src/gateway/openresponses-http.header.test.ts b/src/gateway/openresponses-http.header.test.ts index c3dde3926d0..4e0733e2046 100644 --- a/src/gateway/openresponses-http.header.test.ts +++ b/src/gateway/openresponses-http.header.test.ts @@ -38,4 +38,50 @@ describe("openresponses owner header handling", () => { expect(command.senderIsOwner).toBe(false); }); + + it("ignores x-openclaw-sender-is-owner=true from an untrusted caller", () => { + const command = __testOnlyOpenResponsesHttp.buildResponsesAgentCommandInput({ + req: { + headers: { + "x-openclaw-sender-is-owner": "true", + }, + socket: { remoteAddress: "203.0.113.9" }, + } as never, + message: "hi", + images: [], + clientTools: [], + extraSystemPrompt: "", + streamParams: undefined, + sessionKey: "agent:main:test", + runId: "run-1", + messageChannel: "webchat", + publicMode: true, + trustedProxies: ["127.0.0.1"], + }); + + expect(command.senderIsOwner).toBe(false); + }); + + it("honors x-openclaw-sender-is-owner=true from a trusted proxy", () => { + const command = __testOnlyOpenResponsesHttp.buildResponsesAgentCommandInput({ + req: { + headers: { + "x-openclaw-sender-is-owner": "true", + }, + socket: { remoteAddress: "127.0.0.1" }, + } as never, + message: "hi", + images: [], + clientTools: [], + extraSystemPrompt: "", + streamParams: undefined, + sessionKey: "agent:main:test", + runId: "run-1", + messageChannel: "webchat", + publicMode: true, + trustedProxies: ["127.0.0.1"], + }); + + expect(command.senderIsOwner).toBe(true); + }); }); diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index bddd288cea2..4bee2d4ad44 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -536,7 +536,11 @@ describe("OpenResponses HTTP API (e2e)", () => { it("defaults public-mode agents to non-owner ingress unless explicitly trusted", async () => { testState.agentsConfig = { - list: [{ id: "beta", publicMode: true }], + defaults: { publicMode: true }, + list: [ + { id: "beta", publicMode: false }, + { id: "gamma", publicMode: true }, + ], }; agentCommand.mockClear(); @@ -560,7 +564,7 @@ describe("OpenResponses HTTP API (e2e)", () => { model: "openclaw", input: "hi", }, - { "x-openclaw-agent-id": "beta" }, + { "x-openclaw-agent-id": "gamma" }, ); expect(publicRes.status).toBe(200); const publicOpts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as @@ -570,7 +574,7 @@ describe("OpenResponses HTTP API (e2e)", () => { await ensureResponseConsumed(publicRes); agentCommand.mockClear(); - const trustedPublicRes = await postResponses( + const routedPublicRes = await postResponses( enabledPort, { model: "openclaw", @@ -578,15 +582,15 @@ describe("OpenResponses HTTP API (e2e)", () => { }, { "x-openclaw-agent-id": "beta", - "x-openclaw-sender-is-owner": "true", + "x-openclaw-session-key": "agent:gamma:openresponses:routed-public", }, ); - expect(trustedPublicRes.status).toBe(200); - const trustedPublicOpts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as + expect(routedPublicRes.status).toBe(200); + const routedPublicOpts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as | { senderIsOwner?: boolean } | undefined; - expect(trustedPublicOpts?.senderIsOwner).toBe(true); - await ensureResponseConsumed(trustedPublicRes); + expect(routedPublicOpts?.senderIsOwner).toBe(false); + await ensureResponseConsumed(routedPublicRes); }); it("streams OpenResponses SSE events", async () => { diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index b8f6031806b..58a5cee1e9e 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -8,7 +8,7 @@ import { randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; -import { resolveAgentPublicMode } from "../agents/agent-scope.js"; +import { resolveAgentPublicMode, resolveSessionAgentId } from "../agents/agent-scope.js"; import type { ImageContent } from "../agents/command/types.js"; import type { ClientToolDefinition } from "../agents/pi-embedded-runner/run/params.js"; import { createDefaultDeps } from "../cli/deps.js"; @@ -246,10 +246,12 @@ function buildResponsesAgentCommandInput(params: { runId: string; messageChannel: string; publicMode?: boolean; + trustedProxies?: string[]; }) { const senderIsOwner = resolveIngressSenderIsOwner({ req: params.req, publicMode: params.publicMode, + trustedProxies: params.trustedProxies, }); return { message: params.message, @@ -277,6 +279,7 @@ async function runResponsesAgentCommand(params: { runId: string; messageChannel: string; publicMode?: boolean; + trustedProxies?: string[]; deps: ReturnType; }) { return agentCommandFromIngress( @@ -464,7 +467,7 @@ export async function handleOpenResponsesHttpRequest( }); return true; } - const { agentId, sessionKey, messageChannel } = resolveGatewayRequestContext({ + const { sessionKey, messageChannel } = resolveGatewayRequestContext({ req, model, user, @@ -472,7 +475,9 @@ export async function handleOpenResponsesHttpRequest( defaultMessageChannel: "webchat", useMessageChannelHeader: false, }); - const publicMode = resolveAgentPublicMode(opts.runtimeConfig ?? {}, agentId); + const runtimeConfig = opts.runtimeConfig ?? {}; + const effectiveAgentId = resolveSessionAgentId({ sessionKey, config: runtimeConfig }); + const publicMode = resolveAgentPublicMode(runtimeConfig, effectiveAgentId); // Build prompt from input const prompt = buildAgentPrompt(payload.input); @@ -521,6 +526,7 @@ export async function handleOpenResponsesHttpRequest( runId: responseId, messageChannel, publicMode, + trustedProxies: opts.trustedProxies, deps, });