feat(agents): add portal tool

This commit is contained in:
Peter Steinberger
2026-08-11 22:16:48 -07:00
parent d306ef65a4
commit e388364430
13 changed files with 258 additions and 1 deletions
@@ -74,6 +74,17 @@
"cwd"
]
},
"portal": {
"emoji": "🌐",
"title": "Portal",
"detailKeys": [
"action",
"port",
"id",
"title",
"path"
]
},
"process": {
"emoji": "🧰",
"title": "Process",
@@ -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" },
+10 -1
View File
@@ -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", () => {
+2
View File
@@ -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"
+1
View File
@@ -63,6 +63,7 @@ describe("tool-catalog", () => {
"screen",
"dashboard",
"terminal",
"portal",
"automations",
"get_goal",
"create_goal",
+8
View File
@@ -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",
+5
View File
@@ -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",
+1
View File
@@ -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",
+9
View File
@@ -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",
+4
View File
@@ -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: {
+99
View File
@@ -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<string, unknown>]> = [];
const callGateway: InProcessGatewayCaller = async <T>(
method: string,
params: Record<string, unknown>,
): Promise<T> => {
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([]);
});
});
+104
View File
@@ -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<T>(text: string, payload: T): AgentToolResult<T> {
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=<port> and PUBLIC_URL=<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<string, unknown>;
const action = readToolStringParam(params, "action", { required: true });
if (action === "list") {
const result = await callGateway<PortalListResult>("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<PortalCloseResult>("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<PortalSummary>("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,
);
},
};
}
+3
View File
@@ -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",