feat(protocol): add portal methods and event

Bump the reviewed protocol owner-module count from 55 to 56.
This commit is contained in:
Peter Steinberger
2026-08-11 22:16:35 -07:00
parent c61ee511ab
commit fa00d1fd0b
14 changed files with 323 additions and 2 deletions
@@ -19224,6 +19224,182 @@ public struct ShutdownEvent: Codable, Sendable {
}
}
public struct PortalSummary: Codable, Sendable {
public let id: String
public let title: String
public let port: Int
public let listenport: Int
public let tokenquery: String
public let url: String
public let path: String?
public let description: String?
public let createdatms: Int
public init(
id: String,
title: String,
port: Int,
listenport: Int,
tokenquery: String,
url: String,
path: String? = nil,
description: String? = nil,
createdatms: Int)
{
self.id = id
self.title = title
self.port = port
self.listenport = listenport
self.tokenquery = tokenquery
self.url = url
self.path = path
self.description = description
self.createdatms = createdatms
}
private enum CodingKeys: String, CodingKey {
case id
case title
case port
case listenport = "listenPort"
case tokenquery = "tokenQuery"
case url
case path
case description
case createdatms = "createdAtMs"
}
}
public struct PortalListParams: Codable, Sendable {}
public struct PortalListResult: Codable, Sendable {
public let portals: [PortalOpenResult]
public init(
portals: [PortalOpenResult])
{
self.portals = portals
}
private enum CodingKeys: String, CodingKey {
case portals
}
}
public struct PortalOpenParams: Codable, Sendable {
public let port: Int
public let title: String?
public let description: String?
public let path: String?
public init(
port: Int,
title: String? = nil,
description: String? = nil,
path: String? = nil)
{
self.port = port
self.title = title
self.description = description
self.path = path
}
private enum CodingKeys: String, CodingKey {
case port
case title
case description
case path
}
}
public struct PortalOpenResult: Codable, Sendable {
public let id: String
public let title: String
public let port: Int
public let listenport: Int
public let tokenquery: String
public let url: String
public let path: String?
public let description: String?
public let createdatms: Int
public init(
id: String,
title: String,
port: Int,
listenport: Int,
tokenquery: String,
url: String,
path: String? = nil,
description: String? = nil,
createdatms: Int)
{
self.id = id
self.title = title
self.port = port
self.listenport = listenport
self.tokenquery = tokenquery
self.url = url
self.path = path
self.description = description
self.createdatms = createdatms
}
private enum CodingKeys: String, CodingKey {
case id
case title
case port
case listenport = "listenPort"
case tokenquery = "tokenQuery"
case url
case path
case description
case createdatms = "createdAtMs"
}
}
public struct PortalCloseParams: Codable, Sendable {
public let id: String
public init(
id: String)
{
self.id = id
}
private enum CodingKeys: String, CodingKey {
case id
}
}
public struct PortalCloseResult: Codable, Sendable {
public let closed: Bool
public init(
closed: Bool)
{
self.closed = closed
}
private enum CodingKeys: String, CodingKey {
case closed
}
}
public struct PortalChangedEvent: Codable, Sendable {
public let portals: [PortalOpenResult]
public init(
portals: [PortalOpenResult])
{
self.portals = portals
}
private enum CodingKeys: String, CodingKey {
case portals
}
}
public enum BoardOp: Codable, Sendable {
case tabCreate(BoardTabCreateOp)
case tabUpdate(BoardTabUpdateOp)
+8
View File
@@ -139,6 +139,14 @@ export {
EnvironmentsListResultSchema,
EnvironmentsStatusParamsSchema,
EnvironmentsStatusResultSchema,
PortalSummarySchema,
PortalListParamsSchema,
PortalListResultSchema,
PortalOpenParamsSchema,
PortalOpenResultSchema,
PortalCloseParamsSchema,
PortalCloseResultSchema,
PortalChangedEventSchema,
WorkerDesktopObserveParamsSchema,
WorkerDesktopObserveResultSchema,
WorkerDesktopLaunchParamsSchema,
@@ -50,6 +50,7 @@ export * from "./schema/terminal.js";
export * from "./schema/ui-command.js";
export * from "./schema/plugin-approvals.js";
export * from "./schema/plugins.js";
export * from "./schema/portals.js";
export * from "./schema/projects.js";
export * from "./schema/wizard.js";
export * from "./schema/worker-admission.js";
@@ -0,0 +1,50 @@
import { Value } from "typebox/value";
import { describe, expect, it } from "vitest";
import {
PortalChangedEventSchema,
PortalCloseResultSchema,
PortalListResultSchema,
PortalOpenResultSchema,
PortalSummarySchema,
validatePortalCloseParams,
validatePortalListParams,
validatePortalOpenParams,
} from "../index.js";
const portal = {
id: "p3000",
title: "Development app",
port: 3000,
listenPort: 43123,
tokenQuery: `openclaw_portal=${"a".repeat(64)}`,
url: "http://127.0.0.1:43123/app",
path: "/app",
description: "Live preview",
createdAtMs: 123,
};
describe("portal protocol schemas", () => {
it("accepts closed list, open, and close requests", () => {
expect(validatePortalListParams({})).toBe(true);
expect(validatePortalOpenParams({ port: 3000, title: "Development app", path: "/app" })).toBe(
true,
);
expect(validatePortalCloseParams({ id: "p3000" })).toBe(true);
expect(validatePortalListParams({ extra: true })).toBe(false);
expect(validatePortalOpenParams({ port: 0 })).toBe(false);
expect(validatePortalOpenParams({ port: 65_536 })).toBe(false);
expect(validatePortalOpenParams({ port: 3000, path: "app" })).toBe(false);
expect(validatePortalOpenParams({ port: 3000, host: "example.test" })).toBe(false);
expect(validatePortalCloseParams({ id: "" })).toBe(false);
});
it("validates summaries, results, and full replace-set events", () => {
expect(Value.Check(PortalSummarySchema, portal)).toBe(true);
expect(Value.Check(PortalOpenResultSchema, portal)).toBe(true);
expect(Value.Check(PortalListResultSchema, { portals: [portal] })).toBe(true);
expect(Value.Check(PortalCloseResultSchema, { closed: true })).toBe(true);
expect(Value.Check(PortalChangedEventSchema, { portals: [portal] })).toBe(true);
expect(Value.Check(PortalSummarySchema, { ...portal, targetPort: 3000 })).toBe(false);
expect(Value.Check(PortalChangedEventSchema, { portal })).toBe(false);
});
});
@@ -0,0 +1,44 @@
import { Type, type Static } from "typebox";
import { closedObject } from "./closed-object.js";
import { NonEmptyString } from "./primitives.js";
export const PortalSummarySchema = closedObject({
id: NonEmptyString,
title: NonEmptyString,
port: Type.Integer({ minimum: 1, maximum: 65_535 }),
listenPort: Type.Integer({ minimum: 1, maximum: 65_535 }),
tokenQuery: NonEmptyString,
url: NonEmptyString,
path: Type.Optional(Type.String({ pattern: "^/" })),
description: Type.Optional(Type.String()),
createdAtMs: Type.Integer({ minimum: 0 }),
});
export const PortalListParamsSchema = closedObject({});
export const PortalListResultSchema = closedObject({
portals: Type.Array(PortalSummarySchema),
});
export const PortalOpenParamsSchema = closedObject({
port: Type.Integer({ minimum: 1, maximum: 65_535 }),
title: Type.Optional(NonEmptyString),
description: Type.Optional(Type.String()),
path: Type.Optional(Type.String({ pattern: "^/" })),
});
export const PortalOpenResultSchema = PortalSummarySchema;
export const PortalCloseParamsSchema = closedObject({ id: NonEmptyString });
export const PortalCloseResultSchema = closedObject({ closed: Type.Boolean() });
export const PortalChangedEventSchema = closedObject({
portals: Type.Array(PortalSummarySchema),
});
export type PortalSummary = Static<typeof PortalSummarySchema>;
export type PortalListParams = Static<typeof PortalListParamsSchema>;
export type PortalListResult = Static<typeof PortalListResultSchema>;
export type PortalOpenParams = Static<typeof PortalOpenParamsSchema>;
export type PortalOpenResult = Static<typeof PortalOpenResultSchema>;
export type PortalCloseParams = Static<typeof PortalCloseParamsSchema>;
export type PortalCloseResult = Static<typeof PortalCloseResultSchema>;
export type PortalChangedEvent = Static<typeof PortalChangedEventSchema>;
@@ -0,0 +1,12 @@
import * as portals from "./portals.js";
export const PortalProtocolSchemas = {
PortalSummary: portals.PortalSummarySchema,
PortalListParams: portals.PortalListParamsSchema,
PortalListResult: portals.PortalListResultSchema,
PortalOpenParams: portals.PortalOpenParamsSchema,
PortalOpenResult: portals.PortalOpenResultSchema,
PortalCloseParams: portals.PortalCloseParamsSchema,
PortalCloseResult: portals.PortalCloseResultSchema,
PortalChangedEvent: portals.PortalChangedEventSchema,
} as const;
@@ -8,6 +8,7 @@ import { IntegrationProtocolSchemas } from "./protocol-schema-fragment-integrati
import { NodeProtocolSchemas } from "./protocol-schema-fragment-nodes.js";
import { OperationsProtocolSchemas } from "./protocol-schema-fragment-operations.js";
import { PluginLifecycleProtocolSchemas } from "./protocol-schema-fragment-plugins-lifecycle.js";
import { PortalProtocolSchemas } from "./protocol-schema-fragment-portals.js";
import { SchedulerProtocolSchemas } from "./protocol-schema-fragment-scheduler.js";
import { SessionCollaborationProtocolSchemas } from "./protocol-schema-fragment-sessions-collaboration.js";
import { SessionCoreProtocolSchemas } from "./protocol-schema-fragment-sessions-core.js";
@@ -30,6 +31,7 @@ export const ProtocolSchemas = composeProtocolSchemaFragments([
SchedulerProtocolSchemas,
ApprovalProtocolSchemas,
PluginLifecycleProtocolSchemas,
PortalProtocolSchemas,
] as const);
export {
@@ -155,6 +155,9 @@ export const validateEnvironmentsCreateParams = compile(S.EnvironmentsCreatePara
export const validateEnvironmentsDestroyParams = compile(S.EnvironmentsDestroyParamsSchema);
export const validateEnvironmentsListParams = compile(S.EnvironmentsListParamsSchema);
export const validateEnvironmentsStatusParams = compile(S.EnvironmentsStatusParamsSchema);
export const validatePortalListParams = compile(S.PortalListParamsSchema);
export const validatePortalOpenParams = compile(S.PortalOpenParamsSchema);
export const validatePortalCloseParams = compile(S.PortalCloseParamsSchema);
export const validateWorkerDesktopObserveParams = compile(S.WorkerDesktopObserveParamsSchema);
export const validateWorkerDesktopObserveResult = compile(S.WorkerDesktopObserveResultSchema);
export const validateWorkerDesktopLaunchParams = compile(S.WorkerDesktopLaunchParamsSchema);
@@ -13,6 +13,7 @@
"openclaw.approval.resolved": "OpenClaw system-agent config approvals are a web/desktop operator surface; iOS has no operator-approval prompt.",
"plugin.approval.requested": "Plugin approval prompts are not implemented on iOS.",
"plugin.approval.resolved": "Plugin approval prompts are not implemented on iOS.",
"portal.changed": "Control-UI-only surface; native apps have no portal viewer yet.",
"presence": "Presence roster is a control-UI (web/desktop) surface; iOS does not render it.",
"session.approval": "Native approval review uses exec.approval push/nudge delivery; the session-scoped approval stream is a Control UI chat surface.",
"session.operation": "Chat UI derives run state from chat/agent events; no session.operation consumer yet.",
@@ -44,6 +45,7 @@
"openclaw.approval.resolved": "OpenClaw system-agent config approvals are a web/desktop operator surface; Android has no operator-approval prompt.",
"plugin.approval.requested": "Plugin approval prompts are not implemented on Android.",
"plugin.approval.resolved": "Plugin approval prompts are not implemented on Android.",
"portal.changed": "Control-UI-only surface; native apps have no portal viewer yet.",
"presence": "Presence roster is a control-UI (web/desktop) surface; Android does not render it.",
"session.approval": "Native approval review uses exec.approval push/nudge delivery; the session-scoped approval stream is a Control UI chat surface.",
"session.operation": "Chat UI derives run state from chat/agent events; no session.operation consumer yet.",
@@ -101,6 +101,9 @@ const CURRENT_TRAIN_METHODS = [
"desktop.launch",
"device.scopes.requestUpgrade",
"device.scopes.waitUpgrade",
"portal.list",
"portal.open",
"portal.close",
] as const;
describe("core gateway method release trains", () => {
+3
View File
@@ -520,6 +520,9 @@ const CORE_GATEWAY_METHOD_SPECS = [
// Live device scope upgrades are additive so every older advertised index stays stable.
["device.scopes.requestUpgrade", "devices", "operator.read", "2026.8"],
["device.scopes.waitUpgrade", "devices", "operator.read", "2026.8"],
["portal.list", "portals", "operator.read", "2026.8"],
["portal.open", "portals", "operator.write", "2026.8", { controlPlaneWrite: true }],
["portal.close", "portals", "operator.write", "2026.8", { controlPlaneWrite: true }],
] as const satisfies readonly CoreGatewayMethodSpecRow[];
export type CoreGatewayHandlerFamily = Exclude<(typeof CORE_GATEWAY_METHOD_SPECS)[number][1], null>;
+1
View File
@@ -37,6 +37,7 @@ const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
chat: [READ_SCOPE],
"board.changed": [READ_SCOPE],
"board.command": [READ_SCOPE],
"portal.changed": [READ_SCOPE],
"ui.command": [READ_SCOPE],
"chat.send_timing": [READ_SCOPE],
"chat.side_result": [READ_SCOPE],
+17 -2
View File
@@ -26,6 +26,10 @@ describe("GATEWAY_EVENTS", () => {
expect(GATEWAY_EVENTS).toContain("skills.changed");
});
it("advertises portal replace-set updates", () => {
expect(GATEWAY_EVENTS).toContain("portal.changed");
});
it("advertises session observer digests", () => {
expect(GATEWAY_EVENTS).toContain("session.observer");
});
@@ -66,7 +70,7 @@ describe("listGatewayMethods", () => {
});
it("appends new methods after model probing without shifting older method indices", () => {
expect(listGatewayMethods().slice(-50)).toEqual([
expect(listGatewayMethods().slice(-53)).toEqual([
"models.probe",
"migrations.memory.plan",
"migrations.memory.apply",
@@ -117,6 +121,9 @@ describe("listGatewayMethods", () => {
"desktop.launch",
"device.scopes.requestUpgrade",
"device.scopes.waitUpgrade",
"portal.list",
"portal.open",
"portal.close",
]);
const methods = listGatewayMethods();
expect(methods.indexOf("node.pluginSurface.refresh")).toBe(
@@ -208,7 +215,7 @@ describe("listGatewayMethods", () => {
"exec.approval.get",
]);
expect(methods).toContain("tts.speak");
expect(coreMethods.slice(-57)).toEqual([
expect(coreMethods.slice(-60)).toEqual([
"sessions.catalog.continue",
"sessions.catalog.archive",
"approval.get",
@@ -266,6 +273,9 @@ describe("listGatewayMethods", () => {
"desktop.launch",
"device.scopes.requestUpgrade",
"device.scopes.waitUpgrade",
"portal.list",
"portal.open",
"portal.close",
]);
expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak"));
expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1);
@@ -299,6 +309,11 @@ describe("listGatewayMethods", () => {
expect(methods.indexOf("device.scopes.waitUpgrade")).toBe(
methods.indexOf("device.scopes.requestUpgrade") + 1,
);
expect(methods.indexOf("portal.list")).toBe(
methods.indexOf("device.scopes.waitUpgrade") + 1,
);
expect(methods.indexOf("portal.open")).toBe(methods.indexOf("portal.list") + 1);
expect(methods.indexOf("portal.close")).toBe(methods.indexOf("portal.open") + 1);
});
it("advertises the versioned Talk session RPCs", () => {
+1
View File
@@ -82,5 +82,6 @@ export const GATEWAY_EVENTS = [
"openclaw.approval.resolved",
"terminal.data",
"terminal.exit",
"portal.changed",
GATEWAY_EVENT_UPDATE_AVAILABLE,
];