Merge da944115659c545db83e85862bce48d03203657d into 5e417b44e1540f528d2ae63e3e20229a902d1db2
This commit is contained in:
commit
ffda520d6e
@ -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).
|
||||
|
||||
@ -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:<agentId>"` (example: `"openclaw:main"`, `"openclaw:beta"`)
|
||||
- `model: "agent:<agentId>"` (alias)
|
||||
|
||||
Or target a specific OpenClaw agent by header:
|
||||
|
||||
- `x-openclaw-agent-id: <agentId>` (default: `main`)
|
||||
|
||||
Advanced:
|
||||
|
||||
- `x-openclaw-session-key: <sessionKey>` 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).
|
||||
|
||||
@ -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.
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -27,6 +27,7 @@ type AgentEntry = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[num
|
||||
|
||||
type ResolvedAgentConfig = {
|
||||
name?: string;
|
||||
publicMode?: boolean;
|
||||
workspace?: string;
|
||||
agentDir?: string;
|
||||
model?: AgentEntry["model"];
|
||||
@ -124,8 +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:
|
||||
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:
|
||||
@ -144,6 +152,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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 { createAnthropicVertexStreamFnForModel } from "../../anthropic-vertex-stream.js";
|
||||
import {
|
||||
@ -1672,13 +1672,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({
|
||||
@ -1720,6 +1713,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,
|
||||
@ -1909,6 +1911,7 @@ export async function runEmbeddedAttempt(
|
||||
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
||||
config: params.config,
|
||||
agentId: sessionAgentId,
|
||||
publicMode,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
cwd: process.cwd(),
|
||||
runtime: {
|
||||
@ -1940,6 +1943,7 @@ export async function runEmbeddedAttempt(
|
||||
|
||||
const appendPrompt = buildEmbeddedSystemPrompt({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
publicMode,
|
||||
defaultThinkLevel: params.thinkLevel,
|
||||
reasoningLevel: params.reasoningLevel ?? "off",
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -13,9 +13,15 @@ async function makeRepoRoot(root: string): Promise<void> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<RuntimeInfoInput, "agentId">;
|
||||
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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}` : "",
|
||||
|
||||
@ -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<CommandsSystemPromptBundle> {
|
||||
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,
|
||||
|
||||
@ -230,6 +230,10 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"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":
|
||||
|
||||
@ -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}. */
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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()];
|
||||
@ -23,6 +24,21 @@ export function getBearerToken(req: IncomingMessage): string | undefined {
|
||||
return token || 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 isTrustedProxyAddress(params.req.socket?.remoteAddress, params.trustedProxies);
|
||||
}
|
||||
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() ||
|
||||
|
||||
@ -65,4 +65,71 @@ 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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -636,6 +636,65 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults public-mode agents to non-owner ingress unless explicitly trusted", async () => {
|
||||
testState.agentsConfig = {
|
||||
defaults: { publicMode: true },
|
||||
list: [
|
||||
{ id: "beta", publicMode: false },
|
||||
{ id: "gamma", 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": "gamma" },
|
||||
);
|
||||
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 routedPublicRes = await postChatCompletions(
|
||||
enabledPort,
|
||||
{
|
||||
model: "openclaw",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
},
|
||||
{
|
||||
"x-openclaw-agent-id": "beta",
|
||||
"x-openclaw-session-key": "agent:gamma:openai:routed-public",
|
||||
},
|
||||
);
|
||||
expect(routedPublicRes.status).toBe(200);
|
||||
const routedPublicOpts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as
|
||||
| { senderIsOwner?: boolean }
|
||||
| undefined;
|
||||
expect(routedPublicOpts?.senderIsOwner).toBe(false);
|
||||
await routedPublicRes.text();
|
||||
});
|
||||
|
||||
it("streams SSE chunks when stream=true", async () => {
|
||||
const port = enabledPort;
|
||||
try {
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
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";
|
||||
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,19 @@ 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;
|
||||
trustedProxies?: string[];
|
||||
}) {
|
||||
const senderIsOwner = resolveIngressSenderIsOwner({
|
||||
req: params.req,
|
||||
publicMode: params.publicMode,
|
||||
trustedProxies: params.trustedProxies,
|
||||
});
|
||||
return {
|
||||
message: params.prompt.message,
|
||||
extraSystemPrompt: params.prompt.extraSystemPrompt,
|
||||
@ -115,8 +126,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 +325,7 @@ async function resolveImagesForRequest(
|
||||
}
|
||||
|
||||
export const __testOnlyOpenAiHttp = {
|
||||
buildAgentCommandInput,
|
||||
resolveImagesForRequest,
|
||||
resolveOpenAiChatCompletionsLimits,
|
||||
};
|
||||
@ -440,6 +451,9 @@ export async function handleOpenAiHttpRequest(
|
||||
defaultMessageChannel: "webchat",
|
||||
useMessageChannelHeader: true,
|
||||
});
|
||||
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[] = [];
|
||||
@ -469,6 +483,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 +492,8 @@ export async function handleOpenAiHttpRequest(
|
||||
sessionKey,
|
||||
runId,
|
||||
messageChannel,
|
||||
publicMode,
|
||||
trustedProxies: opts.trustedProxies,
|
||||
});
|
||||
|
||||
if (!stream) {
|
||||
|
||||
87
src/gateway/openresponses-http.header.test.ts
Normal file
87
src/gateway/openresponses-http.header.test.ts
Normal file
@ -0,0 +1,87 @@
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -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,65 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults public-mode agents to non-owner ingress unless explicitly trusted", async () => {
|
||||
testState.agentsConfig = {
|
||||
defaults: { publicMode: true },
|
||||
list: [
|
||||
{ id: "beta", publicMode: false },
|
||||
{ id: "gamma", 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": "gamma" },
|
||||
);
|
||||
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 routedPublicRes = await postResponses(
|
||||
enabledPort,
|
||||
{
|
||||
model: "openclaw",
|
||||
input: "hi",
|
||||
},
|
||||
{
|
||||
"x-openclaw-agent-id": "beta",
|
||||
"x-openclaw-session-key": "agent:gamma:openresponses:routed-public",
|
||||
},
|
||||
);
|
||||
expect(routedPublicRes.status).toBe(200);
|
||||
const routedPublicOpts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as
|
||||
| { senderIsOwner?: boolean }
|
||||
| undefined;
|
||||
expect(routedPublicOpts?.senderIsOwner).toBe(false);
|
||||
await ensureResponseConsumed(routedPublicRes);
|
||||
});
|
||||
|
||||
it("streams OpenResponses SSE events", async () => {
|
||||
const port = enabledPort;
|
||||
try {
|
||||
|
||||
@ -8,10 +8,12 @@
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
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";
|
||||
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,55 @@ async function runResponsesAgentCommand(params: {
|
||||
sessionKey: string;
|
||||
runId: string;
|
||||
messageChannel: string;
|
||||
publicMode?: boolean;
|
||||
trustedProxies?: string[];
|
||||
}) {
|
||||
const senderIsOwner = resolveIngressSenderIsOwner({
|
||||
req: params.req,
|
||||
publicMode: params.publicMode,
|
||||
trustedProxies: params.trustedProxies,
|
||||
});
|
||||
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;
|
||||
trustedProxies?: string[];
|
||||
deps: ReturnType<typeof createDefaultDeps>;
|
||||
}) {
|
||||
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,
|
||||
@ -445,6 +475,9 @@ export async function handleOpenResponsesHttpRequest(
|
||||
defaultMessageChannel: "webchat",
|
||||
useMessageChannelHeader: false,
|
||||
});
|
||||
const runtimeConfig = opts.runtimeConfig ?? {};
|
||||
const effectiveAgentId = resolveSessionAgentId({ sessionKey, config: runtimeConfig });
|
||||
const publicMode = resolveAgentPublicMode(runtimeConfig, effectiveAgentId);
|
||||
|
||||
// Build prompt from input
|
||||
const prompt = buildAgentPrompt(payload.input);
|
||||
@ -483,6 +516,7 @@ export async function handleOpenResponsesHttpRequest(
|
||||
if (!stream) {
|
||||
try {
|
||||
const result = await runResponsesAgentCommand({
|
||||
req,
|
||||
message: prompt.message,
|
||||
images,
|
||||
clientTools: resolvedClientTools,
|
||||
@ -491,6 +525,8 @@ export async function handleOpenResponsesHttpRequest(
|
||||
sessionKey,
|
||||
runId: responseId,
|
||||
messageChannel,
|
||||
publicMode,
|
||||
trustedProxies: opts.trustedProxies,
|
||||
deps,
|
||||
});
|
||||
|
||||
@ -710,6 +746,7 @@ export async function handleOpenResponsesHttpRequest(
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await runResponsesAgentCommand({
|
||||
req,
|
||||
message: prompt.message,
|
||||
images,
|
||||
clientTools: resolvedClientTools,
|
||||
@ -718,6 +755,7 @@ export async function handleOpenResponsesHttpRequest(
|
||||
sessionKey,
|
||||
runId: responseId,
|
||||
messageChannel,
|
||||
publicMode,
|
||||
deps,
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user