diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 96c95b68bf4d..20016adcf9a8 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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) diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 52541d3e6f5b..de8464a7112b 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -139,6 +139,14 @@ export { EnvironmentsListResultSchema, EnvironmentsStatusParamsSchema, EnvironmentsStatusResultSchema, + PortalSummarySchema, + PortalListParamsSchema, + PortalListResultSchema, + PortalOpenParamsSchema, + PortalOpenResultSchema, + PortalCloseParamsSchema, + PortalCloseResultSchema, + PortalChangedEventSchema, WorkerDesktopObserveParamsSchema, WorkerDesktopObserveResultSchema, WorkerDesktopLaunchParamsSchema, diff --git a/packages/gateway-protocol/src/schema-modules.ts b/packages/gateway-protocol/src/schema-modules.ts index 264fa2b433ca..5c4d85e8fb1d 100644 --- a/packages/gateway-protocol/src/schema-modules.ts +++ b/packages/gateway-protocol/src/schema-modules.ts @@ -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"; diff --git a/packages/gateway-protocol/src/schema/portals.test.ts b/packages/gateway-protocol/src/schema/portals.test.ts new file mode 100644 index 000000000000..f08da600cdd3 --- /dev/null +++ b/packages/gateway-protocol/src/schema/portals.test.ts @@ -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); + }); +}); diff --git a/packages/gateway-protocol/src/schema/portals.ts b/packages/gateway-protocol/src/schema/portals.ts new file mode 100644 index 000000000000..3052b7b5e9dd --- /dev/null +++ b/packages/gateway-protocol/src/schema/portals.ts @@ -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; +export type PortalListParams = Static; +export type PortalListResult = Static; +export type PortalOpenParams = Static; +export type PortalOpenResult = Static; +export type PortalCloseParams = Static; +export type PortalCloseResult = Static; +export type PortalChangedEvent = Static; diff --git a/packages/gateway-protocol/src/schema/protocol-schema-fragment-portals.ts b/packages/gateway-protocol/src/schema/protocol-schema-fragment-portals.ts new file mode 100644 index 000000000000..0c25310c14d0 --- /dev/null +++ b/packages/gateway-protocol/src/schema/protocol-schema-fragment-portals.ts @@ -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; diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index 4cdced0389f9..6face49e5f31 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -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 { diff --git a/packages/gateway-protocol/src/validator-registry.ts b/packages/gateway-protocol/src/validator-registry.ts index 878ec76a46d5..b7062006651a 100644 --- a/packages/gateway-protocol/src/validator-registry.ts +++ b/packages/gateway-protocol/src/validator-registry.ts @@ -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); diff --git a/scripts/protocol-event-coverage.allowlist.json b/scripts/protocol-event-coverage.allowlist.json index 59e2d5f597b9..ddbcdab8c7fe 100644 --- a/scripts/protocol-event-coverage.allowlist.json +++ b/scripts/protocol-event-coverage.allowlist.json @@ -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.", diff --git a/src/gateway/methods/core-descriptors.since.test.ts b/src/gateway/methods/core-descriptors.since.test.ts index 4d4995974c48..e460d5389aef 100644 --- a/src/gateway/methods/core-descriptors.since.test.ts +++ b/src/gateway/methods/core-descriptors.since.test.ts @@ -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", () => { diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index bcb70fc67c45..14a7fc8a6d05 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -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>; diff --git a/src/gateway/server-broadcast.ts b/src/gateway/server-broadcast.ts index 9679606f64d3..cd9c167f3bae 100644 --- a/src/gateway/server-broadcast.ts +++ b/src/gateway/server-broadcast.ts @@ -37,6 +37,7 @@ const EVENT_SCOPE_GUARDS: Record = { 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], diff --git a/src/gateway/server-methods-list.test.ts b/src/gateway/server-methods-list.test.ts index eb883732a690..fa466b1a9846 100644 --- a/src/gateway/server-methods-list.test.ts +++ b/src/gateway/server-methods-list.test.ts @@ -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", () => { diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 486d00817a9e..7480b722e558 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -82,5 +82,6 @@ export const GATEWAY_EVENTS = [ "openclaw.approval.resolved", "terminal.data", "terminal.exit", + "portal.changed", GATEWAY_EVENT_UPDATE_AVAILABLE, ];