mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(agents): add portal tool
This commit is contained in:
@@ -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" },
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -63,6 +63,7 @@ describe("tool-catalog", () => {
|
||||
"screen",
|
||||
"dashboard",
|
||||
"terminal",
|
||||
"portal",
|
||||
"automations",
|
||||
"get_goal",
|
||||
"create_goal",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user