Gateway: wire publicMode defaults and trust guards
This commit is contained in:
parent
d5ccc3dd41
commit
da94411565
32
src/agents/agent-scope.public-mode.test.ts
Normal file
32
src/agents/agent-scope.public-mode.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}. */
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user