From d5ccc3dd41e7b983105964ae003c8ee1b08a86fa Mon Sep 17 00:00:00 2001 From: Alex Alaniz Date: Fri, 20 Mar 2026 00:20:01 -0400 Subject: [PATCH 1/2] feat: add publicMode for public-facing agents Add per-agent `publicMode` configuration that suppresses local runtime details (hostname, OS, architecture, workspace paths, skills snapshot) from the system prompt when serving public-facing agents via the HTTP API. - New `publicMode?: boolean` field in agent config schema - `resolveAgentPublicMode()` in agent-scope for config lookup - System prompt emits `"Runtime: public-facing agent"` instead of host/OS/arch details when publicMode is active - Skills snapshot suppressed for publicMode agents - New `X-OpenClaw-Sender-Is-Owner` HTTP header for both chat completions and OpenResponses endpoints, replacing the hardcoded `senderIsOwner: true` - For publicMode agents, missing header defaults to non-owner semantics; for normal agents, missing header defaults to owner semantics - `resolveIngressSenderIsOwner()` utility in http-utils - Documentation updates for security boundary, header usage, and config - Tests for system prompt suppression, param redaction, and header parsing Co-Authored-By: Claude Opus 4.6 --- docs/gateway/openai-http-api.md | 10 +- docs/gateway/openresponses-http-api.md | 68 +++++++++ docs/gateway/security/index.md | 3 +- src/agents/agent-scope.ts | 6 + src/agents/cli-runner/helpers.ts | 13 +- src/agents/pi-embedded-runner/run/attempt.ts | 20 +-- .../pi-embedded-runner/system-prompt.ts | 12 +- src/agents/system-prompt-params.test.ts | 26 +++- src/agents/system-prompt-params.ts | 29 ++-- src/agents/system-prompt.test.ts | 41 ++++++ src/agents/system-prompt.ts | 134 ++++++++++-------- .../reply/commands-system-prompt.ts | 19 ++- src/config/schema.help.ts | 4 + src/config/types.agents.ts | 2 + src/config/zod-schema.agent-runtime.ts | 1 + src/gateway/http-utils.ts | 14 ++ src/gateway/openai-http.image-budget.test.ts | 29 ++++ src/gateway/openai-http.test.ts | 55 +++++++ src/gateway/openai-http.ts | 20 ++- src/gateway/openresponses-http.header.test.ts | 41 ++++++ src/gateway/openresponses-http.test.ts | 57 +++++++- src/gateway/openresponses-http.ts | 68 ++++++--- src/gateway/server-http.ts | 2 + 23 files changed, 558 insertions(+), 116 deletions(-) create mode 100644 src/gateway/openresponses-http.header.test.ts diff --git a/docs/gateway/openai-http-api.md b/docs/gateway/openai-http-api.md index 722b3fdf706..79ebe87149f 100644 --- a/docs/gateway/openai-http-api.md +++ b/docs/gateway/openai-http-api.md @@ -30,13 +30,15 @@ Notes: ## Security boundary (important) -Treat this endpoint as a **full operator-access** surface for the gateway instance. +Treat this endpoint as an **operator-access** surface for the gateway instance unless you +intentionally target a `publicMode` agent through a trusted upstream. - HTTP bearer auth here is not a narrow per-user scope model. -- A valid Gateway token/password for this endpoint should be treated like an owner/operator credential. +- A valid Gateway token/password for this endpoint should still 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. -- If the target agent policy allows sensitive tools, this endpoint can use them. +- For normal agents, missing `x-openclaw-sender-is-owner` defaults to owner semantics. +- For agents with `publicMode: true`, missing `x-openclaw-sender-is-owner` defaults to non-owner semantics; only an explicit `x-openclaw-sender-is-owner: true` restores owner access. +- `publicMode` is not a full per-user sandbox. It redacts local runtime details in prompts and strips owner-only tools, but any non-owner tools still allowed by the target agent remain callable. - Keep this endpoint on loopback/tailnet/private ingress only; do not expose it directly to the public internet. See [Security](/gateway/security) and [Remote access](/gateway/remote). diff --git a/docs/gateway/openresponses-http-api.md b/docs/gateway/openresponses-http-api.md index 8305da62ee5..edcc65d3ac7 100644 --- a/docs/gateway/openresponses-http-api.md +++ b/docs/gateway/openresponses-http-api.md @@ -29,6 +29,74 @@ Operational behavior matches [OpenAI Chat Completions](/gateway/openai-http-api) Enable or disable this endpoint with `gateway.http.endpoints.responses.enabled`. +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`. + +## Security boundary (important) + +Treat this endpoint as an **operator-access** surface for the gateway instance unless you +intentionally target a `publicMode` agent through a trusted upstream. + +- HTTP bearer auth here is not a narrow per-user scope model. +- A valid Gateway token/password for this endpoint should still be treated like an owner/operator credential. +- Requests run through the same control-plane agent path as trusted operator actions. +- For normal agents, missing `x-openclaw-sender-is-owner` defaults to owner semantics. +- For agents with `publicMode: true`, missing `x-openclaw-sender-is-owner` defaults to non-owner semantics; only an explicit `x-openclaw-sender-is-owner: true` restores owner access. +- `publicMode` is not a full per-user sandbox. It redacts local runtime details in prompts and strips owner-only tools, but any non-owner tools still allowed by the target agent remain callable. +- Keep this endpoint on loopback/tailnet/private ingress only; do not expose it directly to the public internet. + +See [Security](/gateway/security) and [Remote access](/gateway/remote). + +## Choosing an agent + +No custom headers required: encode the agent id in the OpenResponses `model` field: + +- `model: "openclaw:"` (example: `"openclaw:main"`, `"openclaw:beta"`) +- `model: "agent:"` (alias) + +Or target a specific OpenClaw agent by header: + +- `x-openclaw-agent-id: ` (default: `main`) + +Advanced: + +- `x-openclaw-session-key: ` to fully control session routing. + +## Enabling the endpoint + +Set `gateway.http.endpoints.responses.enabled` to `true`: + +```json5 +{ + gateway: { + http: { + endpoints: { + responses: { enabled: true }, + }, + }, + }, +} +``` + +## Disabling the endpoint + +Set `gateway.http.endpoints.responses.enabled` to `false`: + +```json5 +{ + gateway: { + http: { + endpoints: { + responses: { enabled: false }, + }, + }, + }, +} +``` + ## Session behavior By default the endpoint is **stateless per request** (a new session key is generated each call). diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 26cfbc4d6df..4da6b478d86 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -803,8 +803,9 @@ still require token/password auth. Important boundary note: -- Gateway HTTP bearer auth is effectively all-or-nothing operator access. +- Gateway HTTP bearer auth is effectively operator access for the gateway. - Treat credentials that can call `/v1/chat/completions`, `/v1/responses`, `/tools/invoke`, or `/api/channels/*` as full-access operator secrets for that gateway. +- For `/v1/chat/completions` and `/v1/responses`, agents with `publicMode: true` default missing `x-openclaw-sender-is-owner` to non-owner semantics, but that only strips owner-only tools and prompt metadata. It is not a full per-user sandbox. - 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. diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 5425b033dca..1283e95eee3 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -27,6 +27,7 @@ type AgentEntry = NonNullable["list"]>[num type ResolvedAgentConfig = { name?: string; + publicMode?: boolean; workspace?: string; agentDir?: string; model?: AgentEntry["model"]; @@ -126,6 +127,7 @@ export function resolveAgentConfig( } return { name: typeof entry.name === "string" ? entry.name : undefined, + publicMode: entry.publicMode === true ? true : undefined, workspace: typeof entry.workspace === "string" ? entry.workspace : undefined, agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined, model: @@ -144,6 +146,10 @@ export function resolveAgentConfig( }; } +export function resolveAgentPublicMode(cfg: OpenClawConfig, agentId: string): boolean { + return resolveAgentConfig(cfg, agentId)?.publicMode === true; +} + export function resolveAgentSkillsFilter( cfg: OpenClawConfig, agentId: string, diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index 98289396112..96f07f385df 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -10,6 +10,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { CliBackendConfig } from "../../config/types.js"; import { buildTtsSystemPromptHint } from "../../tts/tts.js"; import { isRecord } from "../../utils.js"; +import { resolveAgentPublicMode, resolveDefaultAgentId } from "../agent-scope.js"; import { buildModelAliasLines } from "../model-alias-lines.js"; import { resolveDefaultModelForAgent } from "../model-selection.js"; import { resolveOwnerDisplaySetting } from "../owner-display.js"; @@ -51,14 +52,21 @@ export function buildSystemPrompt(params: { modelDisplay: string; agentId?: string; }) { + const resolvedAgentId = + params.agentId ?? (params.config ? resolveDefaultAgentId(params.config) : undefined); + const publicMode = + params.config && resolvedAgentId + ? resolveAgentPublicMode(params.config, resolvedAgentId) + : false; const defaultModelRef = resolveDefaultModelForAgent({ cfg: params.config ?? {}, - agentId: params.agentId, + agentId: resolvedAgentId, }); const defaultModelLabel = `${defaultModelRef.provider}/${defaultModelRef.model}`; const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({ config: params.config, - agentId: params.agentId, + agentId: resolvedAgentId, + publicMode, workspaceDir: params.workspaceDir, cwd: process.cwd(), runtime: { @@ -75,6 +83,7 @@ export function buildSystemPrompt(params: { const ownerDisplay = resolveOwnerDisplaySetting(params.config); return buildAgentSystemPrompt({ workspaceDir: params.workspaceDir, + publicMode, defaultThinkLevel: params.defaultThinkLevel, extraSystemPrompt: params.extraSystemPrompt, ownerNumbers: params.ownerNumbers, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index f89759606de..e7e1debba96 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -34,7 +34,7 @@ import { resolveUserPath } from "../../../utils.js"; import { normalizeMessageChannel } from "../../../utils/message-channel.js"; import { isReasoningTagProvider } from "../../../utils/provider-utils.js"; import { resolveOpenClawAgentDir } from "../../agent-paths.js"; -import { resolveSessionAgentIds } from "../../agent-scope.js"; +import { resolveAgentPublicMode, resolveSessionAgentIds } from "../../agent-scope.js"; import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js"; import { analyzeBootstrapBudget, @@ -1438,13 +1438,6 @@ export async function runEmbeddedAttempt( config: params.config, }); - const skillsPrompt = resolveSkillsPromptForRun({ - skillsSnapshot: params.skillsSnapshot, - entries: shouldLoadSkillEntries ? skillEntries : undefined, - config: params.config, - workspaceDir: effectiveWorkspace, - }); - const sessionLabel = params.sessionKey ?? params.sessionId; const { bootstrapFiles: hookAdjustedBootstrapFiles, contextFiles } = await resolveBootstrapContextForRun({ @@ -1486,6 +1479,15 @@ export async function runEmbeddedAttempt( config: params.config, agentId: params.agentId, }); + const publicMode = resolveAgentPublicMode(params.config ?? {}, sessionAgentId); + const skillsPrompt = publicMode + ? "" + : resolveSkillsPromptForRun({ + skillsSnapshot: params.skillsSnapshot, + entries: shouldLoadSkillEntries ? skillEntries : undefined, + config: params.config, + workspaceDir: effectiveWorkspace, + }); const effectiveFsWorkspaceOnly = resolveAttemptFsWorkspaceOnly({ config: params.config, sessionAgentId, @@ -1675,6 +1677,7 @@ export async function runEmbeddedAttempt( const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({ config: params.config, agentId: sessionAgentId, + publicMode, workspaceDir: effectiveWorkspace, cwd: process.cwd(), runtime: { @@ -1706,6 +1709,7 @@ export async function runEmbeddedAttempt( const appendPrompt = buildEmbeddedSystemPrompt({ workspaceDir: effectiveWorkspace, + publicMode, defaultThinkLevel: params.thinkLevel, reasoningLevel: params.reasoningLevel ?? "off", extraSystemPrompt: params.extraSystemPrompt, diff --git a/src/agents/pi-embedded-runner/system-prompt.ts b/src/agents/pi-embedded-runner/system-prompt.ts index ef246d1af23..75f6dd05d0a 100644 --- a/src/agents/pi-embedded-runner/system-prompt.ts +++ b/src/agents/pi-embedded-runner/system-prompt.ts @@ -10,6 +10,7 @@ import type { ReasoningLevel, ThinkLevel } from "./utils.js"; export function buildEmbeddedSystemPrompt(params: { workspaceDir: string; + publicMode?: boolean; defaultThinkLevel?: ThinkLevel; reasoningLevel?: ReasoningLevel; extraSystemPrompt?: string; @@ -32,11 +33,11 @@ export function buildEmbeddedSystemPrompt(params: { acpEnabled?: boolean; runtimeInfo: { agentId?: string; - host: string; - os: string; - arch: string; - node: string; - model: string; + host?: string; + os?: string; + arch?: string; + node?: string; + model?: string; provider?: string; capabilities?: string[]; channel?: string; @@ -55,6 +56,7 @@ export function buildEmbeddedSystemPrompt(params: { }): string { return buildAgentSystemPrompt({ workspaceDir: params.workspaceDir, + publicMode: params.publicMode, defaultThinkLevel: params.defaultThinkLevel, reasoningLevel: params.reasoningLevel, extraSystemPrompt: params.extraSystemPrompt, diff --git a/src/agents/system-prompt-params.test.ts b/src/agents/system-prompt-params.test.ts index a4215d3a869..54d57171a6d 100644 --- a/src/agents/system-prompt-params.test.ts +++ b/src/agents/system-prompt-params.test.ts @@ -13,9 +13,15 @@ async function makeRepoRoot(root: string): Promise { await fs.mkdir(path.join(root, ".git"), { recursive: true }); } -function buildParams(params: { config?: OpenClawConfig; workspaceDir?: string; cwd?: string }) { +function buildParams(params: { + config?: OpenClawConfig; + publicMode?: boolean; + workspaceDir?: string; + cwd?: string; +}) { return buildSystemPromptParams({ config: params.config, + publicMode: params.publicMode, workspaceDir: params.workspaceDir, cwd: params.cwd, runtime: { @@ -101,4 +107,22 @@ describe("buildSystemPromptParams repo root", () => { expect(runtimeInfo.repoRoot).toBeUndefined(); }); + + it("strips sensitive runtime fields in public mode", async () => { + const temp = await makeTempDir("public"); + const repoRoot = path.join(temp, "repo"); + const workspaceDir = path.join(repoRoot, "workspace"); + await fs.mkdir(workspaceDir, { recursive: true }); + await makeRepoRoot(repoRoot); + + const { runtimeInfo } = buildParams({ publicMode: true, workspaceDir }); + + expect(runtimeInfo.host).toBeUndefined(); + expect(runtimeInfo.os).toBeUndefined(); + expect(runtimeInfo.arch).toBeUndefined(); + expect(runtimeInfo.repoRoot).toBeUndefined(); + expect(runtimeInfo.shell).toBeUndefined(); + expect(runtimeInfo.node).toBe("node"); + expect(runtimeInfo.model).toBe("model"); + }); }); diff --git a/src/agents/system-prompt-params.ts b/src/agents/system-prompt-params.ts index 8aca020420f..a12e7208642 100644 --- a/src/agents/system-prompt-params.ts +++ b/src/agents/system-prompt-params.ts @@ -11,11 +11,11 @@ import { export type RuntimeInfoInput = { agentId?: string; - host: string; - os: string; - arch: string; - node: string; - model: string; + host?: string; + os?: string; + arch?: string; + node?: string; + model?: string; defaultModel?: string; shell?: string; channel?: string; @@ -35,6 +35,7 @@ export type SystemPromptRuntimeParams = { export function buildSystemPromptParams(params: { config?: OpenClawConfig; agentId?: string; + publicMode?: boolean; runtime: Omit; workspaceDir?: string; cwd?: string; @@ -47,12 +48,20 @@ export function buildSystemPromptParams(params: { const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat); const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat); + const runtimeInfo: RuntimeInfoInput = { + agentId: params.agentId, + ...params.runtime, + repoRoot, + }; + if (params.publicMode) { + runtimeInfo.host = undefined; + runtimeInfo.os = undefined; + runtimeInfo.arch = undefined; + runtimeInfo.repoRoot = undefined; + runtimeInfo.shell = undefined; + } return { - runtimeInfo: { - agentId: params.agentId, - ...params.runtime, - repoRoot, - }, + runtimeInfo, userTimezone, userTime, userTimeFormat, diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index b20a9524941..4f3791d9904 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -637,6 +637,47 @@ describe("buildAgentSystemPrompt", () => { expect(line).toContain("thinking=low"); }); + it("redacts runtime and workspace details in public mode", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/Users/alex/openclaw", + publicMode: true, + docsPath: "/Users/alex/openclaw/docs", + runtimeInfo: { + agentId: "community", + host: "Alexs-Mac-Mini", + repoRoot: "/Users/alex/openclaw", + os: "Darwin 25.3.0", + arch: "arm64", + node: "v25.2.1", + model: "openai/gpt-5", + defaultModel: "openai/gpt-5", + shell: "/bin/zsh", + }, + sandboxInfo: { + enabled: true, + workspaceDir: "/Users/alex/openclaw", + containerWorkspaceDir: "/workspace", + workspaceAccess: "rw", + agentWorkspaceMount: "/mnt/agent", + browserNoVncUrl: "http://internal.example/vnc", + elevated: { allowed: true, defaultLevel: "ask" }, + }, + }); + + expect(prompt).toContain("Runtime: public-facing agent"); + expect(prompt).toContain( + "Your working directory is: managed internally for this public-facing agent", + ); + expect(prompt).toContain("Agent workspace access: rw"); + expect(prompt).not.toContain("agent=community"); + expect(prompt).not.toContain("host=Alexs-Mac-Mini"); + expect(prompt).not.toContain("model=openai/gpt-5"); + expect(prompt).not.toContain("OpenClaw docs: /Users/alex/openclaw/docs"); + expect(prompt).not.toContain("/Users/alex/openclaw"); + expect(prompt).not.toContain("/workspace"); + expect(prompt).not.toContain("http://internal.example/vnc"); + }); + it("describes sandboxed runtime and elevated when allowed", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 3ee438db2d4..e0faba2015e 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -168,14 +168,21 @@ function buildVoiceSection(params: { isMinimal: boolean; ttsHint?: string }) { return ["## Voice (TTS)", hint, ""]; } -function buildDocsSection(params: { docsPath?: string; isMinimal: boolean; readToolName: string }) { +function buildDocsSection(params: { + docsPath?: string; + isMinimal: boolean; + publicMode?: boolean; + readToolName: string; +}) { const docsPath = params.docsPath?.trim(); if (!docsPath || params.isMinimal) { return []; } - return [ - "## Documentation", - `OpenClaw docs: ${docsPath}`, + const lines = ["## Documentation"]; + if (!params.publicMode) { + lines.push(`OpenClaw docs: ${docsPath}`); + } + lines.push( "Mirror: https://docs.openclaw.ai", "Source: https://github.com/openclaw/openclaw", "Community: https://discord.com/invite/clawd", @@ -183,11 +190,13 @@ function buildDocsSection(params: { docsPath?: string; isMinimal: boolean; readT "For OpenClaw behavior, commands, config, or architecture: consult local docs first.", "When diagnosing issues, run `openclaw status` yourself when possible; only ask the user if you lack access (e.g., sandboxed).", "", - ]; + ); + return lines; } export function buildAgentSystemPrompt(params: { workspaceDir: string; + publicMode?: boolean; defaultThinkLevel?: ThinkLevel; reasoningLevel?: ReasoningLevel; extraSystemPrompt?: string; @@ -233,6 +242,7 @@ export function buildAgentSystemPrompt(params: { }; memoryCitationsMode?: MemoryCitationsMode; }) { + const publicMode = params.publicMode === true; const acpEnabled = params.acpEnabled !== false; const sandboxedRuntime = params.sandboxInfo?.enabled === true; const acpSpawnRuntimeEnabled = acpEnabled && !sandboxedRuntime; @@ -384,14 +394,60 @@ export function buildAgentSystemPrompt(params: { const sanitizedSandboxContainerWorkspace = sandboxContainerWorkspace ? sanitizeForPromptLiteral(sandboxContainerWorkspace) : ""; - const displayWorkspaceDir = - params.sandboxInfo?.enabled && sanitizedSandboxContainerWorkspace + const displayWorkspaceDir = publicMode + ? "managed internally for this public-facing agent" + : params.sandboxInfo?.enabled && sanitizedSandboxContainerWorkspace ? sanitizedSandboxContainerWorkspace : sanitizedWorkspaceDir; - const workspaceGuidance = - params.sandboxInfo?.enabled && sanitizedSandboxContainerWorkspace + const workspaceGuidance = publicMode + ? "Prefer relative paths for file operations. Do not rely on host-specific absolute paths unless the user explicitly provides them." + : params.sandboxInfo?.enabled && sanitizedSandboxContainerWorkspace ? `For read/write/edit/apply_patch, file paths resolve against host workspace: ${sanitizedWorkspaceDir}. For bash/exec commands, use sandbox container paths under ${sanitizedSandboxContainerWorkspace} (or relative paths from that workdir), not host paths. Prefer relative paths so both sandboxed exec and file tools work consistently.` : "Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise."; + const sandboxSection = params.sandboxInfo?.enabled + ? [ + "You are running in a sandboxed runtime (tools execute in Docker).", + "Some tools may be unavailable due to sandbox policy.", + "Sub-agents stay sandboxed (no elevated/host access). Need outside-sandbox read/write? Don't spawn; ask first.", + hasSessionsSpawn && acpEnabled + ? 'ACP harness spawns are blocked from sandboxed sessions (`sessions_spawn` with `runtime: "acp"`). Use `runtime: "subagent"` instead.' + : "", + !publicMode && params.sandboxInfo.containerWorkspaceDir + ? `Sandbox container workdir: ${sanitizeForPromptLiteral(params.sandboxInfo.containerWorkspaceDir)}` + : "", + !publicMode && params.sandboxInfo.workspaceDir + ? `Sandbox host mount source (file tools bridge only; not valid inside sandbox exec): ${sanitizeForPromptLiteral(params.sandboxInfo.workspaceDir)}` + : "", + params.sandboxInfo.workspaceAccess + ? `Agent workspace access: ${params.sandboxInfo.workspaceAccess}${ + !publicMode && params.sandboxInfo.agentWorkspaceMount + ? ` (mounted at ${sanitizeForPromptLiteral(params.sandboxInfo.agentWorkspaceMount)})` + : "" + }` + : "", + params.sandboxInfo.browserBridgeUrl ? "Sandbox browser: enabled." : "", + !publicMode && params.sandboxInfo.browserNoVncUrl + ? `Sandbox browser observer (noVNC): ${sanitizeForPromptLiteral(params.sandboxInfo.browserNoVncUrl)}` + : "", + params.sandboxInfo.hostBrowserAllowed === true + ? "Host browser control: allowed." + : params.sandboxInfo.hostBrowserAllowed === false + ? "Host browser control: blocked." + : "", + params.sandboxInfo.elevated?.allowed ? "Elevated exec is available for this session." : "", + params.sandboxInfo.elevated?.allowed + ? "User can toggle with /elevated on|off|ask|full." + : "", + params.sandboxInfo.elevated?.allowed + ? "You may also send /elevated on|off|ask|full when needed." + : "", + params.sandboxInfo.elevated?.allowed + ? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (ask runs exec on host with approvals; full auto-approves).` + : "", + ] + .filter(Boolean) + .join("\n") + : ""; const safetySection = [ "## Safety", "You have no independent goals: do not pursue self-preservation, replication, resource acquisition, or power-seeking; avoid long-term plans beyond the user's request.", @@ -411,6 +467,7 @@ export function buildAgentSystemPrompt(params: { const docsSection = buildDocsSection({ docsPath: params.docsPath, isMinimal, + publicMode, readToolName, }); const workspaceNotes = (params.workspaceNotes ?? []).map((note) => note.trim()).filter(Boolean); @@ -515,52 +572,7 @@ export function buildAgentSystemPrompt(params: { "", ...docsSection, params.sandboxInfo?.enabled ? "## Sandbox" : "", - params.sandboxInfo?.enabled - ? [ - "You are running in a sandboxed runtime (tools execute in Docker).", - "Some tools may be unavailable due to sandbox policy.", - "Sub-agents stay sandboxed (no elevated/host access). Need outside-sandbox read/write? Don't spawn; ask first.", - hasSessionsSpawn && acpEnabled - ? 'ACP harness spawns are blocked from sandboxed sessions (`sessions_spawn` with `runtime: "acp"`). Use `runtime: "subagent"` instead.' - : "", - params.sandboxInfo.containerWorkspaceDir - ? `Sandbox container workdir: ${sanitizeForPromptLiteral(params.sandboxInfo.containerWorkspaceDir)}` - : "", - params.sandboxInfo.workspaceDir - ? `Sandbox host mount source (file tools bridge only; not valid inside sandbox exec): ${sanitizeForPromptLiteral(params.sandboxInfo.workspaceDir)}` - : "", - params.sandboxInfo.workspaceAccess - ? `Agent workspace access: ${params.sandboxInfo.workspaceAccess}${ - params.sandboxInfo.agentWorkspaceMount - ? ` (mounted at ${sanitizeForPromptLiteral(params.sandboxInfo.agentWorkspaceMount)})` - : "" - }` - : "", - params.sandboxInfo.browserBridgeUrl ? "Sandbox browser: enabled." : "", - params.sandboxInfo.browserNoVncUrl - ? `Sandbox browser observer (noVNC): ${sanitizeForPromptLiteral(params.sandboxInfo.browserNoVncUrl)}` - : "", - params.sandboxInfo.hostBrowserAllowed === true - ? "Host browser control: allowed." - : params.sandboxInfo.hostBrowserAllowed === false - ? "Host browser control: blocked." - : "", - params.sandboxInfo.elevated?.allowed - ? "Elevated exec is available for this session." - : "", - params.sandboxInfo.elevated?.allowed - ? "User can toggle with /elevated on|off|ask|full." - : "", - params.sandboxInfo.elevated?.allowed - ? "You may also send /elevated on|off|ask|full when needed." - : "", - params.sandboxInfo.elevated?.allowed - ? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (ask runs exec on host with approvals; full auto-approves).` - : "", - ] - .filter(Boolean) - .join("\n") - : "", + sandboxSection, params.sandboxInfo?.enabled ? "" : "", ...buildUserIdentitySection(ownerLine, isMinimal), ...buildTimeSection({ @@ -672,7 +684,13 @@ export function buildAgentSystemPrompt(params: { lines.push( "## Runtime", - buildRuntimeLine(runtimeInfo, runtimeChannel, runtimeCapabilities, params.defaultThinkLevel), + buildRuntimeLine( + runtimeInfo, + runtimeChannel, + runtimeCapabilities, + params.defaultThinkLevel, + publicMode, + ), `Reasoning: ${reasoningLevel} (hidden unless on/stream). Toggle /reasoning; /status shows Reasoning when enabled.`, ); @@ -694,7 +712,11 @@ export function buildRuntimeLine( runtimeChannel?: string, runtimeCapabilities: string[] = [], defaultThinkLevel?: ThinkLevel, + publicMode?: boolean, ): string { + if (publicMode) { + return "Runtime: public-facing agent"; + } return `Runtime: ${[ runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "", runtimeInfo?.host ? `host=${runtimeInfo.host}` : "", diff --git a/src/auto-reply/reply/commands-system-prompt.ts b/src/auto-reply/reply/commands-system-prompt.ts index 18b2e337d72..e18c3c950ec 100644 --- a/src/auto-reply/reply/commands-system-prompt.ts +++ b/src/auto-reply/reply/commands-system-prompt.ts @@ -1,5 +1,5 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; -import { resolveSessionAgentIds } from "../../agents/agent-scope.js"; +import { resolveAgentPublicMode, resolveSessionAgentIds } from "../../agents/agent-scope.js"; import { resolveBootstrapContextForRun } from "../../agents/bootstrap-files.js"; import { resolveDefaultModelForAgent } from "../../agents/model-selection.js"; import type { EmbeddedContextFile } from "../../agents/pi-embedded-helpers.js"; @@ -28,6 +28,13 @@ export async function resolveCommandsSystemPromptBundle( params: HandleCommandsParams, ): Promise { const workspaceDir = params.workspaceDir; + const { sessionAgentId } = resolveSessionAgentIds({ + sessionKey: params.sessionKey, + config: params.cfg, + agentId: params.agentId, + }); + const publicMode = + params.cfg && sessionAgentId ? resolveAgentPublicMode(params.cfg, sessionAgentId) : false; const { bootstrapFiles, contextFiles: injectedFiles } = await resolveBootstrapContextForRun({ workspaceDir, config: params.cfg, @@ -35,6 +42,9 @@ export async function resolveCommandsSystemPromptBundle( sessionId: params.sessionEntry?.sessionId, }); const skillsSnapshot = (() => { + if (publicMode) { + return { prompt: "", skills: [], resolvedSkills: [] }; + } try { return buildWorkspaceSkillSnapshot(workspaceDir, { config: params.cfg, @@ -73,11 +83,6 @@ export async function resolveCommandsSystemPromptBundle( })(); const toolSummaries = buildToolSummaryMap(tools); const toolNames = tools.map((t) => t.name); - const { sessionAgentId } = resolveSessionAgentIds({ - sessionKey: params.sessionKey, - config: params.cfg, - agentId: params.agentId, - }); const defaultModelRef = resolveDefaultModelForAgent({ cfg: params.cfg, agentId: sessionAgentId, @@ -86,6 +91,7 @@ export async function resolveCommandsSystemPromptBundle( const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({ config: params.cfg, agentId: sessionAgentId, + publicMode, workspaceDir, cwd: process.cwd(), runtime: { @@ -112,6 +118,7 @@ export async function resolveCommandsSystemPromptBundle( const systemPrompt = buildAgentSystemPrompt({ workspaceDir, + publicMode, defaultThinkLevel: params.resolvedThinkLevel, reasoningLevel: params.resolvedReasoningLevel, extraSystemPrompt: undefined, diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index bcaec953d57..13f7c99008c 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -230,6 +230,10 @@ export const FIELD_HELP: Record = { "Optional default working directory for this agent's ACP sessions.", "agents.list[].identity.avatar": "Avatar image path (relative to the agent workspace only) or a remote URL/data URL.", + "agents.defaults.publicMode": + "When true, redact local runtime/workspace details in prompts and default HTTP /v1 ingress for public-mode agents to non-owner semantics unless x-openclaw-sender-is-owner=true is explicitly supplied by a trusted upstream.", + "agents.list[].publicMode": + "Per-agent public-mode override. Public-mode agents hide local runtime details in prompts and default HTTP /v1 ingress to non-owner semantics unless a trusted upstream explicitly marks the caller as owner.", "agents.defaults.heartbeat.suppressToolErrorWarnings": "Suppress tool error warning payloads during heartbeat runs.", "agents.list[].heartbeat.suppressToolErrorWarnings": diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index a979506a2ab..3d311d44128 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -62,6 +62,8 @@ export type AgentConfig = { id: string; default?: boolean; name?: string; + /** Hide environment/runtime details from prompts for public-facing agents. */ + publicMode?: boolean; workspace?: string; agentDir?: string; model?: AgentModelConfig; diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 10f0f8637e9..87ea4c28dcf 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -764,6 +764,7 @@ export const AgentEntrySchema = z id: z.string(), default: z.boolean().optional(), name: z.string().optional(), + publicMode: z.boolean().optional(), workspace: z.string().optional(), agentDir: z.string().optional(), model: AgentModelSchema.optional(), diff --git a/src/gateway/http-utils.ts b/src/gateway/http-utils.ts index f3ffa8af7da..ca13f1fafad 100644 --- a/src/gateway/http-utils.ts +++ b/src/gateway/http-utils.ts @@ -23,6 +23,20 @@ export function getBearerToken(req: IncomingMessage): string | undefined { return token || undefined; } +export function resolveIngressSenderIsOwner(params: { + req: IncomingMessage; + publicMode?: boolean; +}): boolean { + const raw = getHeader(params.req, "x-openclaw-sender-is-owner")?.trim().toLowerCase(); + if (raw === "true") { + return true; + } + if (raw === "false") { + return false; + } + return params.publicMode !== true; +} + export function resolveAgentIdFromHeader(req: IncomingMessage): string | undefined { const raw = getHeader(req, "x-openclaw-agent-id")?.trim() || diff --git a/src/gateway/openai-http.image-budget.test.ts b/src/gateway/openai-http.image-budget.test.ts index fcc7e2049ae..0810a49434f 100644 --- a/src/gateway/openai-http.image-budget.test.ts +++ b/src/gateway/openai-http.image-budget.test.ts @@ -65,4 +65,33 @@ describe("openai image budget accounting", () => { }, ]); }); + + it("honors x-openclaw-sender-is-owner=false", () => { + const command = __testOnlyOpenAiHttp.buildAgentCommandInput({ + req: { + headers: { + "x-openclaw-sender-is-owner": "false", + }, + } as never, + prompt: { message: "hi" }, + sessionKey: "agent:main:test", + runId: "run-1", + messageChannel: "webchat", + }); + + expect(command.senderIsOwner).toBe(false); + }); + + it("defaults public-mode ingress to non-owner when the header is missing", () => { + const command = __testOnlyOpenAiHttp.buildAgentCommandInput({ + req: { headers: {} } as never, + prompt: { message: "hi" }, + sessionKey: "agent:main:test", + runId: "run-1", + messageChannel: "webchat", + publicMode: true, + }); + + expect(command.senderIsOwner).toBe(false); + }); }); diff --git a/src/gateway/openai-http.test.ts b/src/gateway/openai-http.test.ts index 82130807a1b..626c6ea6fd3 100644 --- a/src/gateway/openai-http.test.ts +++ b/src/gateway/openai-http.test.ts @@ -636,6 +636,61 @@ 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 }], + }; + + agentCommand.mockClear(); + agentCommand.mockResolvedValue({ payloads: [{ text: "hello" }] } as never); + + const defaultRes = await postChatCompletions(enabledPort, { + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }); + expect(defaultRes.status).toBe(200); + const defaultOpts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as + | { senderIsOwner?: boolean } + | undefined; + expect(defaultOpts?.senderIsOwner).toBe(true); + await defaultRes.text(); + + agentCommand.mockClear(); + const publicRes = await postChatCompletions( + enabledPort, + { + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }, + { "x-openclaw-agent-id": "beta" }, + ); + expect(publicRes.status).toBe(200); + const publicOpts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as + | { senderIsOwner?: boolean } + | undefined; + expect(publicOpts?.senderIsOwner).toBe(false); + await publicRes.text(); + + agentCommand.mockClear(); + const trustedPublicRes = await postChatCompletions( + enabledPort, + { + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }, + { + "x-openclaw-agent-id": "beta", + "x-openclaw-sender-is-owner": "true", + }, + ); + expect(trustedPublicRes.status).toBe(200); + const trustedPublicOpts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as + | { senderIsOwner?: boolean } + | undefined; + expect(trustedPublicOpts?.senderIsOwner).toBe(true); + await trustedPublicRes.text(); + }); + it("streams SSE chunks when stream=true", async () => { const port = enabledPort; try { diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index 5809da5bcee..ba2caced00b 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -1,8 +1,10 @@ import { randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; +import { resolveAgentPublicMode } 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"; +import type { OpenClawConfig } from "../config/config.js"; import type { GatewayHttpChatCompletionsConfig } from "../config/types.gateway.js"; import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js"; import { logWarn } from "../logger.js"; @@ -27,12 +29,13 @@ 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 } from "./http-utils.js"; +import { resolveGatewayRequestContext, resolveIngressSenderIsOwner } from "./http-utils.js"; import { normalizeInputHostnameAllowlist } from "./input-allowlist.js"; type OpenAiHttpOptions = { auth: ResolvedGatewayAuth; config?: GatewayHttpChatCompletionsConfig; + runtimeConfig?: OpenClawConfig; maxBodyBytes?: number; trustedProxies?: string[]; allowRealIpFallback?: boolean; @@ -101,11 +104,17 @@ function writeSse(res: ServerResponse, data: unknown) { } function buildAgentCommandInput(params: { + req: IncomingMessage; prompt: { message: string; extraSystemPrompt?: string; images?: ImageContent[] }; sessionKey: string; runId: string; messageChannel: string; + publicMode?: boolean; }) { + const senderIsOwner = resolveIngressSenderIsOwner({ + req: params.req, + publicMode: params.publicMode, + }); return { message: params.prompt.message, extraSystemPrompt: params.prompt.extraSystemPrompt, @@ -115,8 +124,7 @@ function buildAgentCommandInput(params: { deliver: false as const, messageChannel: params.messageChannel, bestEffortDeliver: false as const, - // HTTP API callers are authenticated operator clients for this gateway context. - senderIsOwner: true as const, + senderIsOwner, allowModelOverride: true as const, }; } @@ -315,6 +323,7 @@ async function resolveImagesForRequest( } export const __testOnlyOpenAiHttp = { + buildAgentCommandInput, resolveImagesForRequest, resolveOpenAiChatCompletionsLimits, }; @@ -432,7 +441,7 @@ export async function handleOpenAiHttpRequest( const model = typeof payload.model === "string" ? payload.model : "openclaw"; const user = typeof payload.user === "string" ? payload.user : undefined; - const { sessionKey, messageChannel } = resolveGatewayRequestContext({ + const { agentId, sessionKey, messageChannel } = resolveGatewayRequestContext({ req, model, user, @@ -440,6 +449,7 @@ export async function handleOpenAiHttpRequest( defaultMessageChannel: "webchat", useMessageChannelHeader: true, }); + const publicMode = resolveAgentPublicMode(opts.runtimeConfig ?? {}, agentId); const activeTurnContext = resolveActiveTurnContext(payload.messages); const prompt = buildAgentPrompt(payload.messages, activeTurnContext.activeUserMessageIndex); let images: ImageContent[] = []; @@ -469,6 +479,7 @@ export async function handleOpenAiHttpRequest( const runId = `chatcmpl_${randomUUID()}`; const deps = createDefaultDeps(); const commandInput = buildAgentCommandInput({ + req, prompt: { message: prompt.message, extraSystemPrompt: prompt.extraSystemPrompt, @@ -477,6 +488,7 @@ export async function handleOpenAiHttpRequest( sessionKey, runId, messageChannel, + publicMode, }); if (!stream) { diff --git a/src/gateway/openresponses-http.header.test.ts b/src/gateway/openresponses-http.header.test.ts new file mode 100644 index 00000000000..c3dde3926d0 --- /dev/null +++ b/src/gateway/openresponses-http.header.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { __testOnlyOpenResponsesHttp } from "./openresponses-http.js"; + +describe("openresponses owner header handling", () => { + it("honors x-openclaw-sender-is-owner=false", () => { + const command = __testOnlyOpenResponsesHttp.buildResponsesAgentCommandInput({ + req: { + headers: { + "x-openclaw-sender-is-owner": "false", + }, + } as never, + message: "hi", + images: [], + clientTools: [], + extraSystemPrompt: "", + streamParams: undefined, + sessionKey: "agent:main:test", + runId: "run-1", + messageChannel: "webchat", + }); + + expect(command.senderIsOwner).toBe(false); + }); + + it("defaults public-mode ingress to non-owner when the header is missing", () => { + const command = __testOnlyOpenResponsesHttp.buildResponsesAgentCommandInput({ + req: { headers: {} } as never, + message: "hi", + images: [], + clientTools: [], + extraSystemPrompt: "", + streamParams: undefined, + sessionKey: "agent:main:test", + runId: "run-1", + messageChannel: "webchat", + publicMode: true, + }); + + expect(command.senderIsOwner).toBe(false); + }); +}); diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index 3a9a5517537..bddd288cea2 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -5,7 +5,7 @@ import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js"; import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js"; import { emitAgentEvent } from "../infra/agent-events.js"; import { buildAssistantDeltaResult } from "./test-helpers.agent-results.js"; -import { agentCommand, getFreePort, installGatewayTestHooks } from "./test-helpers.js"; +import { agentCommand, getFreePort, installGatewayTestHooks, testState } from "./test-helpers.js"; installGatewayTestHooks({ scope: "suite" }); @@ -534,6 +534,61 @@ 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 }], + }; + + agentCommand.mockClear(); + agentCommand.mockResolvedValue({ payloads: [{ text: "hello" }] } as never); + + const defaultRes = await postResponses(enabledPort, { + model: "openclaw", + input: "hi", + }); + expect(defaultRes.status).toBe(200); + const defaultOpts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as + | { senderIsOwner?: boolean } + | undefined; + expect(defaultOpts?.senderIsOwner).toBe(true); + await ensureResponseConsumed(defaultRes); + + agentCommand.mockClear(); + const publicRes = await postResponses( + enabledPort, + { + model: "openclaw", + input: "hi", + }, + { "x-openclaw-agent-id": "beta" }, + ); + expect(publicRes.status).toBe(200); + const publicOpts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as + | { senderIsOwner?: boolean } + | undefined; + expect(publicOpts?.senderIsOwner).toBe(false); + await ensureResponseConsumed(publicRes); + + agentCommand.mockClear(); + const trustedPublicRes = await postResponses( + enabledPort, + { + model: "openclaw", + input: "hi", + }, + { + "x-openclaw-agent-id": "beta", + "x-openclaw-sender-is-owner": "true", + }, + ); + expect(trustedPublicRes.status).toBe(200); + const trustedPublicOpts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as + | { senderIsOwner?: boolean } + | undefined; + expect(trustedPublicOpts?.senderIsOwner).toBe(true); + await ensureResponseConsumed(trustedPublicRes); + }); + it("streams OpenResponses SSE events", async () => { const port = enabledPort; try { diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index 1c440b1571c..b8f6031806b 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -8,10 +8,12 @@ import { randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; +import { resolveAgentPublicMode } 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"; import { agentCommandFromIngress } from "../commands/agent.js"; +import type { OpenClawConfig } from "../config/config.js"; import type { GatewayHttpResponsesConfig } from "../config/types.gateway.js"; import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js"; import { logWarn } from "../logger.js"; @@ -35,7 +37,7 @@ 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 } from "./http-utils.js"; +import { resolveGatewayRequestContext, resolveIngressSenderIsOwner } from "./http-utils.js"; import { normalizeInputHostnameAllowlist } from "./input-allowlist.js"; import { CreateResponseBodySchema, @@ -51,6 +53,7 @@ type OpenResponsesHttpOptions = { auth: ResolvedGatewayAuth; maxBodyBytes?: number; config?: GatewayHttpResponsesConfig; + runtimeConfig?: OpenClawConfig; trustedProxies?: string[]; allowRealIpFallback?: boolean; rateLimiter?: AuthRateLimiter; @@ -232,7 +235,8 @@ function createAssistantOutputItem(params: { }; } -async function runResponsesAgentCommand(params: { +function buildResponsesAgentCommandInput(params: { + req: IncomingMessage; message: string; images: ImageContent[]; clientTools: ClientToolDefinition[]; @@ -241,29 +245,52 @@ async function runResponsesAgentCommand(params: { sessionKey: string; runId: string; messageChannel: string; + publicMode?: boolean; +}) { + const senderIsOwner = resolveIngressSenderIsOwner({ + req: params.req, + publicMode: params.publicMode, + }); + return { + message: params.message, + images: params.images.length > 0 ? params.images : undefined, + clientTools: params.clientTools.length > 0 ? params.clientTools : undefined, + extraSystemPrompt: params.extraSystemPrompt || undefined, + streamParams: params.streamParams ?? undefined, + sessionKey: params.sessionKey, + runId: params.runId, + deliver: false, + messageChannel: params.messageChannel, + bestEffortDeliver: false, + senderIsOwner, + }; +} + +async function runResponsesAgentCommand(params: { + req: IncomingMessage; + message: string; + images: ImageContent[]; + clientTools: ClientToolDefinition[]; + extraSystemPrompt: string; + streamParams: { maxTokens: number } | undefined; + sessionKey: string; + runId: string; + messageChannel: string; + publicMode?: boolean; deps: ReturnType; }) { return agentCommandFromIngress( - { - message: params.message, - images: params.images.length > 0 ? params.images : undefined, - clientTools: params.clientTools.length > 0 ? params.clientTools : undefined, - extraSystemPrompt: params.extraSystemPrompt || undefined, - streamParams: params.streamParams ?? undefined, - sessionKey: params.sessionKey, - runId: params.runId, - deliver: false, - messageChannel: params.messageChannel, - bestEffortDeliver: false, - // HTTP API callers are authenticated operator clients for this gateway context. - senderIsOwner: true, - allowModelOverride: true, - }, + { ...buildResponsesAgentCommandInput(params), allowModelOverride: true }, defaultRuntime, params.deps, ); } +export const __testOnlyOpenResponsesHttp = { + buildResponsesAgentCommandInput, + resolveResponsesLimits, +}; + export async function handleOpenResponsesHttpRequest( req: IncomingMessage, res: ServerResponse, @@ -437,7 +464,7 @@ export async function handleOpenResponsesHttpRequest( }); return true; } - const { sessionKey, messageChannel } = resolveGatewayRequestContext({ + const { agentId, sessionKey, messageChannel } = resolveGatewayRequestContext({ req, model, user, @@ -445,6 +472,7 @@ export async function handleOpenResponsesHttpRequest( defaultMessageChannel: "webchat", useMessageChannelHeader: false, }); + const publicMode = resolveAgentPublicMode(opts.runtimeConfig ?? {}, agentId); // Build prompt from input const prompt = buildAgentPrompt(payload.input); @@ -483,6 +511,7 @@ export async function handleOpenResponsesHttpRequest( if (!stream) { try { const result = await runResponsesAgentCommand({ + req, message: prompt.message, images, clientTools: resolvedClientTools, @@ -491,6 +520,7 @@ export async function handleOpenResponsesHttpRequest( sessionKey, runId: responseId, messageChannel, + publicMode, deps, }); @@ -710,6 +740,7 @@ export async function handleOpenResponsesHttpRequest( void (async () => { try { const result = await runResponsesAgentCommand({ + req, message: prompt.message, images, clientTools: resolvedClientTools, @@ -718,6 +749,7 @@ export async function handleOpenResponsesHttpRequest( sessionKey, runId: responseId, messageChannel, + publicMode, deps, }); diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index ebf81bea62c..72b8500ab54 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -834,6 +834,7 @@ export function createGatewayHttpServer(opts: { handleOpenResponsesHttpRequest(req, res, { auth: resolvedAuth, config: openResponsesConfig, + runtimeConfig: configSnapshot, trustedProxies, allowRealIpFallback, rateLimiter, @@ -847,6 +848,7 @@ export function createGatewayHttpServer(opts: { handleOpenAiHttpRequest(req, res, { auth: resolvedAuth, config: openAiChatCompletionsConfig, + runtimeConfig: configSnapshot, trustedProxies, allowRealIpFallback, rateLimiter, From da944115659c545db83e85862bce48d03203657d Mon Sep 17 00:00:00 2001 From: Alex Alaniz Date: Fri, 20 Mar 2026 22:34:20 -0400 Subject: [PATCH 2/2] 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, });