Gateway: wire publicMode defaults and trust guards

This commit is contained in:
Alex Alaniz 2026-03-20 22:34:20 -04:00
parent d5ccc3dd41
commit da94411565
12 changed files with 172 additions and 24 deletions

View File

@ -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);
});
});

View File

@ -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:

View File

@ -65,7 +65,9 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

View File

@ -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}. */

View File

@ -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(),

View File

@ -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;

View File

@ -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);
});
});

View File

@ -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 () => {

View File

@ -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) {

View File

@ -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);
});
});

View File

@ -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 () => {

View File

@ -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<typeof createDefaultDeps>;
}) {
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,
});