diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json index c4591fdeed03..712cd8d2fb6a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json @@ -74,6 +74,17 @@ "cwd" ] }, + "portal": { + "emoji": "🌐", + "title": "Portal", + "detailKeys": [ + "action", + "port", + "id", + "title", + "path" + ] + }, "process": { "emoji": "🧰", "title": "Process", diff --git a/src/agents/core-tool-factory-descriptors.ts b/src/agents/core-tool-factory-descriptors.ts index 592431c96e7b..e9579b03853f 100644 --- a/src/agents/core-tool-factory-descriptors.ts +++ b/src/agents/core-tool-factory-descriptors.ts @@ -54,6 +54,7 @@ const CORE_TOOL_FACTORY_DESCRIPTORS = [ { name: "create_goal", family: "openclaw" }, { name: "subagents", family: "openclaw" }, { name: "terminal", family: "openclaw" }, + { name: "portal", family: "openclaw" }, { name: "transcripts", family: "openclaw" }, { name: "tts", family: "openclaw" }, { name: "update_goal", family: "openclaw" }, diff --git a/src/agents/openclaw-tools.registration.test.ts b/src/agents/openclaw-tools.registration.test.ts index 6d9edaa47052..d3ff804c46de 100644 --- a/src/agents/openclaw-tools.registration.test.ts +++ b/src/agents/openclaw-tools.registration.test.ts @@ -619,16 +619,25 @@ describe("gateway client capability tool filtering", () => { expect(hasTool(createOpenClawTools({ clientCaps: ["ui-commands"] }), "screen")).toBe(true); }); - it("omits terminal for sandboxed agents", () => { + it("omits host UI runtime tools for sandboxed agents", () => { expect(hasTool(createOpenClawTools({ agentSessionKey: "agent:main:main" }), "terminal")).toBe( true, ); + expect(hasTool(createOpenClawTools({ agentSessionKey: "agent:main:main" }), "portal")).toBe( + true, + ); expect( hasTool( createOpenClawTools({ agentSessionKey: "agent:main:main", sandboxed: true }), "terminal", ), ).toBe(false); + expect( + hasTool( + createOpenClawTools({ agentSessionKey: "agent:main:main", sandboxed: true }), + "portal", + ), + ).toBe(false); }); it("does not let tools.allow resurrect a gated tool for a channel run", () => { diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index b182bf5cd0a6..23f53248366a 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -76,6 +76,7 @@ import { createMusicGenerateTool } from "./tools/music-generate-tool.js"; import { createNodesTool } from "./tools/nodes-tool.js"; import { createOpenClawDelegateToolsForRun } from "./tools/openclaw-delegate-tool.js"; import { createPdfTool } from "./tools/pdf-tool.js"; +import { createPortalTool } from "./tools/portal-tool.js"; import { createScreenTool } from "./tools/screen-tool.js"; import { createSessionStatusTool } from "./tools/session-status-tool.js"; import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js"; @@ -514,6 +515,7 @@ export function createOpenClawTools( agentSessionKey: options?.runSessionKey ?? options?.agentSessionKey, runId: options?.runId, }), + createPortalTool(), ]), ]), ...(!embedded && taskKey && options?.taskSuggestionDeliveryMode === "gateway" diff --git a/src/agents/tool-catalog.test.ts b/src/agents/tool-catalog.test.ts index 01531db3d3ae..02e10daced2c 100644 --- a/src/agents/tool-catalog.test.ts +++ b/src/agents/tool-catalog.test.ts @@ -63,6 +63,7 @@ describe("tool-catalog", () => { "screen", "dashboard", "terminal", + "portal", "automations", "get_goal", "create_goal", diff --git a/src/agents/tool-catalog.ts b/src/agents/tool-catalog.ts index a2d97e5dda95..4d39e0753bcb 100644 --- a/src/agents/tool-catalog.ts +++ b/src/agents/tool-catalog.ts @@ -310,6 +310,14 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [ profiles: ["coding"], includeInOpenClawGroup: true, }, + { + id: "portal", + label: "portal", + description: "Expose local web apps through the gateway", + sectionId: "ui", + profiles: ["coding"], + includeInOpenClawGroup: true, + }, { id: "canvas", label: "canvas", diff --git a/src/agents/tool-display-config.ts b/src/agents/tool-display-config.ts index 4c2887f84098..7a1129fe1b5b 100644 --- a/src/agents/tool-display-config.ts +++ b/src/agents/tool-display-config.ts @@ -70,6 +70,11 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = { title: "Terminal", detailKeys: ["action", "sessionId", "command", "cwd"], }, + portal: { + emoji: "🌐", + title: "Portal", + detailKeys: ["action", "port", "id", "title", "path"], + }, process: { emoji: "🧰", title: "Process", diff --git a/src/agents/tool-mutation-names.ts b/src/agents/tool-mutation-names.ts index 10819a87c375..fe6640c593f5 100644 --- a/src/agents/tool-mutation-names.ts +++ b/src/agents/tool-mutation-names.ts @@ -21,6 +21,7 @@ const MUTATING_TOOL_NAMES = new Set([ // Saved transcripts predate the rename; legacy names must stay classified. ...LEGACY_AUTOMATIONS_TOOL_NAMES, "gateway", + "portal", "canvas", "computer", "mobile_ui", diff --git a/src/agents/tool-mutation.test.ts b/src/agents/tool-mutation.test.ts index fc3875b1a350..0e3a8d5c56fd 100644 --- a/src/agents/tool-mutation.test.ts +++ b/src/agents/tool-mutation.test.ts @@ -20,6 +20,15 @@ describe("tool mutation helpers", () => { ).toBe(true); }); + it("classifies portal list as replay-safe and portal mutations as mutating", () => { + expect(isMutatingToolCall("portal", { action: "list" })).toBe(false); + expect(isReplaySafeToolCall("portal", { action: "list" })).toBe(true); + for (const action of ["open", "close"]) { + expect(isMutatingToolCall("portal", { action }), action).toBe(true); + expect(isReplaySafeToolCall("portal", { action }), action).toBe(false); + } + }); + it("builds stable fingerprints for mutating calls and omits read-only calls", () => { const writeFingerprint = buildToolMutationState( "write", diff --git a/src/agents/tool-mutation.ts b/src/agents/tool-mutation.ts index 4ef5e5780feb..e9b75d9d1ec5 100644 --- a/src/agents/tool-mutation.ts +++ b/src/agents/tool-mutation.ts @@ -362,6 +362,8 @@ export function isMutatingToolCall(toolName: string, args: unknown): boolean { return typeof record?.model === "string" && record.model.trim().length > 0; case "gateway": return action == null || !GATEWAY_REPLAY_SAFE_ACTIONS.has(action); + case "portal": + return action !== "list"; case "nodes": return action == null || !NODES_REPLAY_SAFE_ACTIONS.has(action); default: { @@ -413,6 +415,8 @@ export function isReplaySafeToolCall(toolName: string, args: unknown): boolean { return action === "status"; case "gateway": return action != null && GATEWAY_REPLAY_SAFE_ACTIONS.has(action); + case "portal": + return action === "list"; case "nodes": return action != null && NODES_REPLAY_SAFE_ACTIONS.has(action); default: { diff --git a/src/agents/tools/portal-tool.test.ts b/src/agents/tools/portal-tool.test.ts new file mode 100644 index 000000000000..7a1c77722a54 --- /dev/null +++ b/src/agents/tools/portal-tool.test.ts @@ -0,0 +1,99 @@ +import { Value } from "typebox/value"; +import { describe, expect, it } from "vitest"; +import type { + PortalCloseResult, + PortalListResult, + PortalSummary, +} from "../../../packages/gateway-protocol/src/index.js"; +import { + DEFAULT_GATEWAY_HTTP_TOOL_DENY, + GATEWAY_OWNER_ONLY_CORE_TOOLS, +} from "../../security/dangerous-tools.js"; +import type { InProcessGatewayCaller } from "./in-process-gateway.js"; +import { createPortalTool } from "./portal-tool.js"; + +const portal: PortalSummary = { + id: "p3000", + title: "App", + port: 3000, + listenPort: 43123, + tokenQuery: `openclaw_portal=${"a".repeat(64)}`, + url: "http://127.0.0.1:43123/", + createdAtMs: 1, +}; + +function recorder() { + const calls: Array<[string, Record]> = []; + const callGateway: InProcessGatewayCaller = async ( + method: string, + params: Record, + ): Promise => { + calls.push([method, params]); + if (method === "portal.list") { + return { portals: [portal] } as PortalListResult as T; + } + if (method === "portal.close") { + return { closed: true } as PortalCloseResult as T; + } + return portal as T; + }; + return { calls, callGateway }; +} + +describe("portal tool", () => { + it("uses a flat closed action schema and owner-only security gate", () => { + const tool = createPortalTool(); + expect(tool.parameters).toMatchObject({ + additionalProperties: false, + properties: { action: { enum: ["open", "list", "close"] } }, + }); + expect(Value.Check(tool.parameters, { action: "open", port: 3000, path: "/app" })).toBe(true); + expect(Value.Check(tool.parameters, { action: "open", port: 0 })).toBe(false); + expect(Value.Check(tool.parameters, { action: "open", port: 3000, path: "app" })).toBe(false); + expect(Value.Check(tool.parameters, { action: "unknown" })).toBe(false); + expect(GATEWAY_OWNER_ONLY_CORE_TOOLS).toContain("portal"); + expect(DEFAULT_GATEWAY_HTTP_TOOL_DENY).toContain("portal"); + }); + + it("maps open, list, and close through the in-process gateway caller", async () => { + const recorded = recorder(); + const tool = createPortalTool({ callGateway: recorded.callGateway }); + const opened = await tool.execute("open", { + action: "open", + port: 3000, + title: "App", + description: "Preview", + path: "/app", + }); + const listed = await tool.execute("list", { action: "list" }); + const closed = await tool.execute("close", { action: "close", id: "p3000" }); + + expect(recorded.calls).toEqual([ + ["portal.open", { port: 3000, title: "App", description: "Preview", path: "/app" }], + ["portal.list", {}], + ["portal.close", { id: "p3000" }], + ]); + expect(opened.details).toEqual(portal); + expect(opened.content[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("Control UI Portals page"), + }); + expect(listed.details).toEqual({ portals: [portal] }); + expect(closed.details).toEqual({ closed: true }); + expect(Value.Check(tool.outputSchema!, opened.details)).toBe(true); + expect(Value.Check(tool.outputSchema!, listed.details)).toBe(true); + expect(Value.Check(tool.outputSchema!, closed.details)).toBe(true); + }); + + it("rejects action-specific missing and malformed fields before RPC", async () => { + const recorded = recorder(); + const tool = createPortalTool({ callGateway: recorded.callGateway }); + + await expect(tool.execute("open", { action: "open" })).rejects.toThrow("port required"); + await expect(tool.execute("open", { action: "open", port: 3000, path: "app" })).rejects.toThrow( + "path must start with /", + ); + await expect(tool.execute("close", { action: "close" })).rejects.toThrow("id required"); + expect(recorded.calls).toEqual([]); + }); +}); diff --git a/src/agents/tools/portal-tool.ts b/src/agents/tools/portal-tool.ts new file mode 100644 index 000000000000..c104134108f8 --- /dev/null +++ b/src/agents/tools/portal-tool.ts @@ -0,0 +1,104 @@ +import { Type } from "typebox"; +import { + PortalCloseResultSchema, + PortalListResultSchema, + PortalSummarySchema, + type PortalCloseResult, + type PortalListResult, + type PortalSummary, +} from "../../../packages/gateway-protocol/src/index.js"; +import type { AgentToolResult } from "../runtime/index.js"; +import type { AnyAgentTool } from "./common.js"; +import { + jsonResult, + readPositiveIntegerParam, + readToolStringParam, + ToolInputError, +} from "./common.js"; +import { callInProcessGatewayTool, type InProcessGatewayCaller } from "./in-process-gateway.js"; + +const PORTAL_ACTIONS = ["open", "list", "close"] as const; + +const PortalToolSchema = Type.Object( + { + action: Type.String({ enum: [...PORTAL_ACTIONS], description: "Portal action" }), + port: Type.Optional(Type.Integer({ minimum: 1, maximum: 65_535 })), + title: Type.Optional(Type.String({ minLength: 1 })), + description: Type.Optional(Type.String()), + path: Type.Optional(Type.String({ pattern: "^/" })), + id: Type.Optional(Type.String({ minLength: 1 })), + }, + { additionalProperties: false }, +); + +const PortalToolOutputSchema = Type.Union([ + PortalSummarySchema, + PortalListResultSchema, + PortalCloseResultSchema, +]); + +type PortalToolOptions = { + callGateway?: InProcessGatewayCaller; +}; + +function portalResult(text: string, payload: T): AgentToolResult { + const result = jsonResult(payload); + return { ...result, content: [{ type: "text", text }, ...result.content] }; +} + +export function createPortalTool(options: PortalToolOptions = {}): AnyAgentTool { + const callGateway = options.callGateway ?? callInProcessGatewayTool; + return { + label: "Portal", + name: "portal", + description: + "Expose a local HTTP dev server through the gateway so the operator can view it live (a portal). Flow: pick a port (if the workspace has .openclaw/portals.json, use its declared entries), call action=open with that port to get the portal URL, then start the server with the exec tool (background=true) passing PORT= and PUBLIC_URL= in env. The proxy carries HTTP and WebSockets (hot reload works) and shows a retry page until the server listens. action=list shows active portals; action=close removes one. Portals end when the gateway restarts.", + parameters: PortalToolSchema, + outputSchema: PortalToolOutputSchema, + execute: async (_toolCallId, rawArgs) => { + const params = rawArgs as Record; + const action = readToolStringParam(params, "action", { required: true }); + if (action === "list") { + const result = await callGateway("portal.list", {}); + return portalResult( + `${result.portals.length} active portal${result.portals.length === 1 ? "" : "s"}. The operator can see them in the Control UI Portals page.`, + result, + ); + } + if (action === "close") { + const id = readToolStringParam(params, "id", { required: true }); + const result = await callGateway("portal.close", { id }); + return portalResult( + `Portal ${id} closed. The Control UI Portals page has been updated.`, + result, + ); + } + if (action !== "open") { + throw new ToolInputError(`Unknown portal action: ${action}`); + } + const port = readPositiveIntegerParam(params, "port", { + max: 65_535, + message: "port must be an integer from 1 to 65535", + }); + if (port === undefined) { + throw new ToolInputError("port required"); + } + const title = readToolStringParam(params, "title"); + const description = readToolStringParam(params, "description", { allowEmpty: true }); + const path = readToolStringParam(params, "path"); + if (path !== undefined && !path.startsWith("/")) { + throw new ToolInputError("path must start with /"); + } + const portal = await callGateway("portal.open", { + port, + ...(title !== undefined ? { title } : {}), + ...(description !== undefined ? { description } : {}), + ...(path !== undefined ? { path } : {}), + }); + return portalResult( + `Portal available at ${portal.url}. The operator can see it in the Control UI Portals page.`, + portal, + ); + }, + }; +} diff --git a/src/security/dangerous-tools.ts b/src/security/dangerous-tools.ts index 0f755fc0a3a8..d85920245c9b 100644 --- a/src/security/dangerous-tools.ts +++ b/src/security/dangerous-tools.ts @@ -24,6 +24,8 @@ export const DEFAULT_GATEWAY_HTTP_TOOL_DENY = [ "apply_patch", // Agent-owned host terminal — interactive RCE surface "terminal", + // Local HTTP exposure can publish arbitrary workspace applications. + "portal", // Session orchestration — spawning agents remotely is RCE "sessions_spawn", // Cross-session injection — message injection across sessions @@ -61,6 +63,7 @@ export const GATEWAY_OWNER_ONLY_CORE_TOOLS = [ "sessions", "screen", "terminal", + "portal", "conversations_list", "conversations_send", "conversations_turn",