diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index 2b1f6b151f2a..2ac184f70418 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -578,6 +578,9 @@ enum class GatewayMethod( DesktopLaunch("desktop.launch"), DeviceScopesRequestUpgrade("device.scopes.requestUpgrade"), DeviceScopesWaitUpgrade("device.scopes.waitUpgrade"), + PortalList("portal.list"), + PortalOpen("portal.open"), + PortalClose("portal.close"), } enum class GatewayEvent( @@ -629,4 +632,5 @@ enum class GatewayEvent( TerminalData("terminal.data"), TerminalExit("terminal.exit"), UpdateAvailable("update.available"), + PortalChanged("portal.changed"), } 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/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index f5d0a84e2c40..5f11c393c70e 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -19228,6 +19228,190 @@ 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 publicurl: 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? = nil, + url: String? = nil, + publicurl: 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.publicurl = publicurl + 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 publicurl = "publicUrl" + case path + case description + case createdatms = "createdAtMs" + } +} + +public struct PortalListParams: Codable, Sendable {} + +public struct PortalListResult: Codable, Sendable { + public let portals: [PortalSummary] + + public init( + portals: [PortalSummary]) + { + 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 publicurl: 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, + publicurl: 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.publicurl = publicurl + 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 publicurl = "publicUrl" + 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: [PortalSummary] + + public init( + portals: [PortalSummary]) + { + self.portals = portals + } + + private enum CodingKeys: String, CodingKey { + case portals + } +} + public enum BoardOp: Codable, Sendable { case tabCreate(BoardTabCreateOp) case tabUpdate(BoardTabUpdateOp) diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 8a178ae0c44e..a4e6001a7a95 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"812e818c8d7c013b2287c4502426123222037a4235e130a69481272912062866","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"29e2f1aadc64c83fccf94d7fb4747b10e31bbe0922f4806ed3f23d8c1698e6c3","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 3b4e4add6542..c32a1ee3b0f6 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"f639fea8b8ee53626452bdbce156724b09a3a29bb05a134eb8e8f8fb8062d4da","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"8407ad2fc13c783155999acdbef231efe60ba69538448a955ff5f10888e51d31","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index dbcb1039396a..633b45e2b48c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"02db4abe2f1f4578d438f7afacd68d44114e46b48b9582711e7e10191353e6c5","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"f44495c27167838fbcdb0ba6afaafd770689e33bb57f725b45b8f9ac20af1565","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 45b7d50451ab..8c91b8b4979d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"6826237a93cc52b6039fddbd4b4e0e00e82b5c83cbadd36a055a50ab0480879c","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"29b2feadc45ec3f6c9a7281839142334bf1f8d4ab5fddbed200e99c5e2c51407","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index e74031a0e4a1..eefaef624b7e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"bc8881a906f40f0a3ede29eb83efc1e4d3e59b62c00154b2480d85d69cbe4010","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"90a7e6988de562edab9ee28696207bba9fba6d83488dc62d9e0b893ca9603dc6","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index dd24f428bb5a..6b1e57e366f6 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"f4b35d03ac9df9788462f3e50b64819ff85aba0245b1a51208a288bd994edb8e","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"43cedd1b0efa245682de25aa7922045223c58a004bd6a424f1c98968f50396bf","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index c777e8b2845b..b47a7bd16e8b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"80b5ab5fedd16c952c83747f4093db46e49640b3cfa1fdaa5a9727ff31edf0a7","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"f478b7aa4001f84bb97202d38a75807a67afc2f2c07e074f9121f30298e7493f","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index ca65ae570cac..9dce341a2cfb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"edfa6b7a219aef521935ac17a20c5cd74e91bf4988eb3fad5ac75fc2e5015596","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"41d0227914b1eaf4f05dcbc6a92cbe1c824ae9415df8472a5edc0492f5b764ca","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index edeb54f4fba1..49e1ad32da4d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"6490014377554ea6b62c657ab22c53fcec385b4ad24cdfccd95ebd9a79717e59","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"2e71c5a50c2efb3af7410ffb1cf7b6a6321b4f279c78ab5377fc984321251697","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index 7bbe64aef447..cd0d5bd5ec8f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"b691fcfb34a5f228938d06c50f9f8a26bb63ce06644e9df25f44876601e4cebb","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"067122d86f2c4c36fdebd428eb0183210a4cf3d5ac1ba5f082ae1120b57d14a3","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 78798cf75996..554780e20fd3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"bb5122c6ac5f4dfe381493b9d128a303108434edca9434ad5528781d005271b8","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"afddb2d3e7f79b386ed6074f45f85419a0bcae5a6ce49c0157524851c7261d5e","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index 2f8591069a4b..6cb8dcff330e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"9060d4011e1249ca8aa0b01f2e2825d71b29eff14dc84f7f702440dc01e73460","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"a3a31f78a73ea9159d1779d354b84cff1036741ee3980367c5effc4dd29b1b67","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index eb8a21e5eb31..cef1ddb5ee2a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"a36c13699a318b3fb1b6701ab3280c45e5a4f5ca982cef29778d10af8a5ae97a","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"64593f3e4ff693f2041d34fd5f3ee6e96795b9b2d49bbd60173d89da7d203494","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index b78837d1e262..2285c8c326d4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"bdf0b57a425cac872d156021006cce6813893bee30e77c5ef4c055343697dba0","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"9e2037874e2efb4faa53078d6c71089927097ccf08ecb4ba5ca9bd3f4b64414a","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 084ac522181e..5f7add338b92 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"62f6dc31a2b1789b3667fe16c2d465681aa4c01e2cd9b2a6da062fd995235963","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"bd3c786f5753a95f03474833f1f5a8872c55ebdf3d541b96c558843e06d3aa44","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index 261089d57bc7..6a4d25310237 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"3662752cd7db434787d728355a4fb46e8f4be5b88dece9c29d96225bc02da6db","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"abf5f77043e2e1b8210541ed48d6ab5e22ec475bf05484d5f6103f82cfdd9f38","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index b6f15aef72f9..d4d4d7912b30 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"66ab292503af6befc63d5962f3312a8ebab4ab3bf3a56f47fe4f6a465d7c40b9","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"9c8a8d0ccdb9961abc35ca210b183a307df34e4ca2b1cf645d5db652cd7f8677","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index fdb0b5e8a03b..fae273bbc76d 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -1746,5 +1746,9 @@ { "source": "Connect a machine", "target": "连接机器" + }, + { + "source": "Portals", + "target": "门户" } ] diff --git a/docs/docs.json b/docs/docs.json index 5210dbaaeebe..8a58e8df66ee 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1725,6 +1725,7 @@ "network", "gateway/pairing", "gateway/discovery", + "gateway/portals", "gateway/bonjour" ] } diff --git a/docs/gateway/portals.md b/docs/gateway/portals.md new file mode 100644 index 000000000000..f50a67924c48 --- /dev/null +++ b/docs/gateway/portals.md @@ -0,0 +1,84 @@ +--- +title: "Portals" +summary: "Expose agent-run development servers to the operator through the Gateway" +read_when: + - Showing a development server in the Control UI + - Declaring workspace development servers for an agent + - Troubleshooting portal access or live reload +--- + +Portals expose a development server running on the Gateway host to the operator's browser. They proxy HTTP and WebSockets for live reload and appear in **Control UI → Portals**. + +## Quick start + +Ask the agent to open a portal: + +- "Show me in a portal." +- "Start the app in a portal." + +The agent opens a portal for the application's port, then starts the development server with a background `exec` call. Opening a portal only creates the proxy listener; it does not inject environment variables into your server. The agent sets `PORT` (the port it opened) and `PUBLIC_URL` (the portal's public base URL) in that `exec` command's own environment, so the app binds the expected port and generates correct absolute URLs. + +## Declare development servers + +Optionally commit `.openclaw/portals.json` to the workspace repository so the agent can discover the available development servers: + +```json +{ + "portals": [ + { + "name": "web", + "command": "pnpm dev", + "cwd": ".", + "port": 3000, + "title": "App", + "description": "Use the seeded test account." + } + ] +} +``` + +The Gateway never executes these commands automatically. The agent reads the file and decides when to run a declared server. + +| Field | Required | Description | +| ------------- | -------- | -------------------------------------------------- | +| `name` | yes | Stable name the agent uses to identify the server. | +| `command` | yes | Command the agent starts with background `exec`. | +| `port` | yes | Local TCP port the application listens on. | +| `cwd` | no | Working directory relative to the workspace root. | +| `title` | no | Display title shown on the Portals page. | +| `description` | no | Operator guidance shown beside the portal. | +| `path` | no | Initial URL path. It must begin with `/`. | + +## Application contract + +The application must honor `PORT`. Use `PUBLIC_URL` when it needs to generate absolute URLs. + +The proxy rewrites `Host` to the local target, so typical development servers such as Vite and Next.js need no additional configuration. WebSockets and hot module replacement are proxied through the same portal. + +## Security model + +Each portal uses a separate origin on its own port and binds to the same interfaces as the Gateway. Access requires the token in the portal URL. On the first request, the proxy stores that token in an HttpOnly cookie and removes it from subsequent upstream requests. The proxy validates this cookie itself and never forwards it to the application. + +Browser cookies are hostname-scoped rather than port-scoped, so the proxy isolates each application's cookie jar with an `oc_portal__` name prefix. Requests forward only cookies with that portal's prefix and strip it before reaching the application; Gateway cookies, unprefixed cookies, and cookies for other portals are dropped. Application `Set-Cookie` responses receive the prefix, and any `Domain` attribute is removed so the cookie stays host-only. + +Portals proxy only the selected local development server. They never serve Gateway data, and every portal ends when the Gateway restarts. + +## Limitations + +- The development server must run on the Gateway host. Remote worker support is planned. +- A proxy or tunnel in front of the Gateway does not automatically expose portal listener ports. The Control UI detects this and shows a reachable URL with retry guidance instead of mounting a dead iframe. +- Browser-side cookie code sees the prefixed names in `document.cookie`. Applications that manage cookies in browser code must account for the prefix; unprefixed cookies written directly by browser code are not forwarded to the target. + +## Troubleshooting + +### The portal shows a 502 waiting page + +The proxy is ready, but the application is not listening on the selected port. The page retries automatically. Check the background process and confirm that the server honors `PORT`. + +### The portal is not reachable from this browser + +The Control UI could reach the Gateway but could not reach the portal's separate listener port. This commonly happens when a proxy or tunnel exposes only the main Gateway port. Open the displayed portal URL from a browser on the Gateway host, or expose that portal listener port through the same network path, then select **Retry**. + +### Close a portal + +Ask the agent to "close the portal," or use the close button on the **Control UI → Portals** page. diff --git a/docs/web/urls.md b/docs/web/urls.md index 6899fdb62a0f..7ad00a92846f 100644 --- a/docs/web/urls.md +++ b/docs/web/urls.md @@ -137,6 +137,7 @@ no route-specific URL parameters. | New session | `/new` | - | `?agent=`, `?catalog=` | | Activity | `/activity` | - | `?view=run&run=`, `?view=run&execution=` | | Apps | `/apps` | - | - | +| Portals | `/portals` | - | - | | Agents | `/settings/agents` | `/agents` | `/settings/agents/[/]` | | Channels | `/settings/channels` | `/channels` | Shared settings parameters below | | Connection | `/settings/connection` | - | Shared settings parameters below | diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 52541d3e6f5b..856a01de03bc 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -48,6 +48,7 @@ export type { SecretsStoreMutationResult, SecretsStoreSetParams, } from "./schema/secrets.js"; +export * from "./schema/portals.js"; // Explicit schema exports keep public protocol changes reviewable. export { isCloudWorkerPlacementState, 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..c6ba93fcdae3 --- /dev/null +++ b/packages/gateway-protocol/src/schema/portals.test.ts @@ -0,0 +1,56 @@ +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?openclaw_portal=${"a".repeat(64)}`, + publicUrl: "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); + const { tokenQuery: _tokenQuery, url: _url, ...redactedPortal } = portal; + expect(Value.Check(PortalSummarySchema, redactedPortal)).toBe(true); + expect(Value.Check(PortalOpenResultSchema, redactedPortal)).toBe(false); + expect(Value.Check(PortalCloseResultSchema, { closed: true })).toBe(true); + expect(Value.Check(PortalChangedEventSchema, { portals: [portal] })).toBe(true); + const { publicUrl: _publicUrl, ...missingPublicUrl } = portal; + expect(Value.Check(PortalSummarySchema, missingPublicUrl)).toBe(false); + 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..6eee3cc4dd60 --- /dev/null +++ b/packages/gateway-protocol/src/schema/portals.ts @@ -0,0 +1,58 @@ +import { Type, type Static } from "typebox"; +import { closedObject } from "./closed-object.js"; +import { NonEmptyString } from "./primitives.js"; + +const PortalSummaryIdentityFields = { + id: NonEmptyString, + title: NonEmptyString, + port: Type.Integer({ minimum: 1, maximum: 65_535 }), + listenPort: Type.Integer({ minimum: 1, maximum: 65_535 }), +}; + +const PortalSummaryMetadataFields = { + publicUrl: NonEmptyString, + path: Type.Optional(Type.String({ pattern: "^/" })), + description: Type.Optional(Type.String()), + createdAtMs: Type.Integer({ minimum: 0 }), +}; + +export const PortalSummarySchema = closedObject({ + ...PortalSummaryIdentityFields, + tokenQuery: Type.Optional(NonEmptyString), + url: Type.Optional(NonEmptyString), + ...PortalSummaryMetadataFields, +}); + +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 = closedObject({ + ...PortalSummaryIdentityFields, + tokenQuery: NonEmptyString, + url: NonEmptyString, + ...PortalSummaryMetadataFields, +}); + +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/check-protocol-registry.mts b/scripts/check-protocol-registry.mts index 8da7b07f4be0..dbbc0a649908 100644 --- a/scripts/check-protocol-registry.mts +++ b/scripts/check-protocol-registry.mts @@ -113,8 +113,8 @@ const ownerModules = [ ...schemaModulesSource.matchAll(/^export \* from "\.\/schema\/([^"]+)\.js";$/gmu), ].map(([, moduleName = ""]) => moduleName); check( - ownerModules.length === 56 && new Set(ownerModules).size === ownerModules.length, - "schema-modules.ts must contain one unique 56-module owner list", + ownerModules.length === 57 && new Set(ownerModules).size === ownerModules.length, + "schema-modules.ts must contain one unique 57-module owner list", ); check( schemaModulesSource.split("\n").filter(Boolean).length === ownerModules.length, 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/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..233256490a12 --- /dev/null +++ b/src/agents/tools/portal-tool.test.ts @@ -0,0 +1,100 @@ +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/?openclaw_portal=${"a".repeat(64)}`, + publicUrl: "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: `Portal available at ${portal.url}. Pass PUBLIC_URL=${portal.publicUrl} and PORT=${portal.port} when starting the dev server. The operator can see it in the 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..e2fd9b88532a --- /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}. Pass PUBLIC_URL=${portal.publicUrl} and PORT=${portal.port} when starting the dev server. The operator can see it in the Control UI Portals page.`, + portal, + ); + }, + }; +} diff --git a/src/gateway/control-ui-csp.test.ts b/src/gateway/control-ui-csp.test.ts index 1c148c5fc2c1..d774dbbad588 100644 --- a/src/gateway/control-ui-csp.test.ts +++ b/src/gateway/control-ui-csp.test.ts @@ -36,6 +36,19 @@ describe("buildControlUiCspHeader", () => { expect(connectSrc?.split(" ")).not.toContain("https:"); }); + it("allows portal probes only across ports on the current document host", () => { + const csp = buildControlUiCspHeader({ portalHost: "gateway.example.test:18789" }); + const connectSrc = csp.split("; ").find((directive) => directive.startsWith("connect-src ")); + expect(connectSrc?.split(" ")).toContain("http://gateway.example.test:*"); + expect(connectSrc?.split(" ")).toContain("https://gateway.example.test:*"); + expect(connectSrc?.split(" ")).not.toContain("https:"); + + const invalid = buildControlUiCspHeader({ + portalHost: "gateway.example.test/path;connect-src https://example.test", + }); + expect(invalid).not.toContain("https://example.test"); + }); + it("limits image loading to local sources and the Gravatar fallback origin", () => { const csp = buildControlUiCspHeader(); const imgSrc = csp.split("; ").find((directive) => directive.startsWith("img-src ")); diff --git a/src/gateway/control-ui-csp.ts b/src/gateway/control-ui-csp.ts index 1bb0199e852e..59816cf2fd3b 100644 --- a/src/gateway/control-ui-csp.ts +++ b/src/gateway/control-ui-csp.ts @@ -37,6 +37,8 @@ function hasScriptSrcAttribute(openTag: string): boolean { /** Build the CSP header applied to Gateway-served Control UI HTML. */ export function buildControlUiCspHeader(opts?: { inlineScriptHashes?: string[]; + /** Current document Host header, used only to permit cross-port portal probes. */ + portalHost?: string; /** * Relax the policy just enough for the embedded terminal's ghostty-web engine. * `'wasm-unsafe-eval'` permits WebAssembly compilation. Gated on the terminal @@ -62,6 +64,22 @@ export function buildControlUiCspHeader(opts?: { "https://api.openai.com", "https://tweakcn.com", ]; + if (opts?.portalHost) { + try { + const parsed = new URL(`http://${opts.portalHost}`); + const isHostOnly = + !parsed.username && + !parsed.password && + parsed.pathname === "/" && + !parsed.search && + !parsed.hash; + if (isHostOnly && parsed.hostname) { + connectTokens.push(`http://${parsed.hostname}:*`, `https://${parsed.hostname}:*`); + } + } catch { + // Invalid Host headers do not relax the baseline policy. + } + } return [ "default-src 'self'", "base-uri 'none'", diff --git a/src/gateway/control-ui.auto-root.http.test.ts b/src/gateway/control-ui.auto-root.http.test.ts index 617feef2e804..d0c7270176f6 100644 --- a/src/gateway/control-ui.auto-root.http.test.ts +++ b/src/gateway/control-ui.auto-root.http.test.ts @@ -53,7 +53,11 @@ describe("handleControlUiHttpRequest prepared root lifecycle", () => { await fs.link(sourceIndex, indexPath); const { res, end } = makeMockHttpResponse(); const handled = await handleControlUiHttpRequest( - { url: "/dashboard", method: "GET" } as IncomingMessage, + { + url: "/dashboard", + method: "GET", + headers: { host: "gateway.example.test" }, + } as IncomingMessage, res, { root: { kind: "bundled", path: tmp, realPath: await fs.realpath(tmp) } }, ); diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index ce875306c7d6..9c36b382b0c6 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -529,7 +529,7 @@ describe("handleControlUiHttpRequest", () => { fn: async (tmp) => { const { res, end, setHeader } = makeMockHttpResponse(); const handled = await handleControlUiHttpRequest( - { url: "/", method: "GET" } as IncomingMessage, + { url: "/", method: "GET", headers: { host: "gateway.example.test" } } as IncomingMessage, res, { root: { kind: "resolved", path: tmp }, @@ -563,7 +563,7 @@ describe("handleControlUiHttpRequest", () => { fn: async (tmp) => { const { res, end, setHeader } = makeMockHttpResponse(); const handled = await handleControlUiHttpRequest( - { url: "/", method: "GET" } as IncomingMessage, + { url: "/", method: "GET", headers: { host: "gateway.example.test" } } as IncomingMessage, res, { root: { kind: "resolved", path: tmp }, @@ -584,11 +584,15 @@ describe("handleControlUiHttpRequest", () => { await withControlUiRoot({ fn: async (tmp) => { const { res, end, setHeader } = makeMockHttpResponse(); - await handleControlUiHttpRequest({ url: "/", method: "GET" } as IncomingMessage, res, { - root: { kind: "resolved", path: tmp }, - config: { gateway: { terminal: { enabled: true } } }, - terminalEnabled: false, - }); + await handleControlUiHttpRequest( + { url: "/", method: "GET", headers: { host: "gateway.example.test" } } as IncomingMessage, + res, + { + root: { kind: "resolved", path: tmp }, + config: { gateway: { terminal: { enabled: true } } }, + terminalEnabled: false, + }, + ); const csp = setHeader.mock.calls.findLast( (call) => call[0] === "Content-Security-Policy", )?.[1]; @@ -1484,9 +1488,11 @@ describe("handleControlUiHttpRequest", () => { indexHtml: html, fn: async (tmp) => { const { res, setHeader } = makeMockHttpResponse(); - await handleControlUiHttpRequest({ url: "/", method: "GET" } as IncomingMessage, res, { - root: { kind: "resolved", path: tmp }, - }); + await handleControlUiHttpRequest( + { url: "/", method: "GET", headers: { host: "gateway.example.test" } } as IncomingMessage, + res, + { root: { kind: "resolved", path: tmp } }, + ); const cspCalls = setHeader.mock.calls.filter( (call) => call[0] === "Content-Security-Policy", ); @@ -1504,7 +1510,7 @@ describe("handleControlUiHttpRequest", () => { fn: async (tmp) => { const { res, end } = makeMockHttpResponse(); const handled = await handleControlUiHttpRequest( - { url: "/", method: "GET" } as IncomingMessage, + { url: "/", method: "GET", headers: { host: "gateway.example.test" } } as IncomingMessage, res, { root: { kind: "resolved", path: tmp }, @@ -1530,7 +1536,11 @@ describe("handleControlUiHttpRequest", () => { fn: async (tmp) => { const { res, end } = makeMockHttpResponse(); const handled = await handleControlUiHttpRequest( - { url: "/openclaw/chat", method: "GET" } as IncomingMessage, + { + url: "/openclaw/chat", + method: "GET", + headers: { host: "gateway.example.test" }, + } as IncomingMessage, res, { basePath: "/openclaw", @@ -1578,7 +1588,11 @@ describe("handleControlUiHttpRequest", () => { fn: async (tmp) => { const { res, end } = makeMockHttpResponse(); const handled = await handleControlUiHttpRequest( - { url: requestPath, method: "GET" } as IncomingMessage, + { + url: requestPath, + method: "GET", + headers: { host: "gateway.example.test" }, + } as IncomingMessage, res, { ...(basePath ? { basePath } : {}), diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 385269f78510..dfb9c49657f7 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -908,7 +908,11 @@ async function serveResolvedIndexHtml( // terminal's WASM relaxation is applied to the page that loads ghostty-web. res.setHeader( "Content-Security-Policy", - buildControlUiCspHeader({ inlineScriptHashes: hashes, allowWasm }), + buildControlUiCspHeader({ + inlineScriptHashes: hashes, + allowWasm, + portalHost: req.headers.host, + }), ); res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Cache-Control", "no-cache"); diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index 322141ed7315..8dc265a6183f 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -110,7 +110,11 @@ describe("GatewayClient", () => { ) { const { res } = makeControlUiResponse(); const handled = await handleControlUiHttpRequest( - { url: params.url, method: params.method ?? "GET" } as IncomingMessage, + { + url: params.url, + method: params.method ?? "GET", + headers: { host: "gateway.example.test" }, + } as IncomingMessage, res, { root: { kind: "resolved", path: tmp } }, ); diff --git a/src/gateway/methods/core-descriptors.since.test.ts b/src/gateway/methods/core-descriptors.since.test.ts index f435f7a6eac5..5218b0410e3f 100644 --- a/src/gateway/methods/core-descriptors.since.test.ts +++ b/src/gateway/methods/core-descriptors.since.test.ts @@ -102,6 +102,9 @@ const CURRENT_TRAIN_METHODS = [ "device.scopes.requestUpgrade", "device.scopes.waitUpgrade", "node.protocolFeatures.update", + "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 879e2f7af7cb..8060d6fc4f86 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -521,6 +521,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/portals/portal-http-proxy.test.ts b/src/gateway/portals/portal-http-proxy.test.ts new file mode 100644 index 000000000000..ad29a20717fc --- /dev/null +++ b/src/gateway/portals/portal-http-proxy.test.ts @@ -0,0 +1,525 @@ +import { + createServer, + request, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { type RawData, WebSocket, WebSocketServer } from "ws"; +import { createGatewayPortalService, type GatewayPortalService } from "./portal-service.js"; + +type HttpResult = { + status: number; + headers: IncomingMessage["headers"]; + body: string; +}; + +let targetPort = 0; +let targetHandler: (req: IncomingMessage, res: ServerResponse) => void; +let targetWebSocketPath: string | undefined; +let targetWebSocketCookie: string | undefined; +let targetWebSocketSetCookie: string | undefined; +const targetServer = createServer((req, res) => targetHandler(req, res)); +const targetWss = new WebSocketServer({ server: targetServer }); +const services = new Set(); +const temporaryTargetServers = new Set(); + +beforeAll(async () => { + targetWss.on("connection", (socket, req) => { + targetWebSocketPath = req.url; + targetWebSocketCookie = req.headers.cookie; + socket.on("message", (data) => socket.send(data)); + }); + targetWss.on("headers", (headers) => { + if (targetWebSocketSetCookie) { + headers.push(`Set-Cookie: ${targetWebSocketSetCookie}`); + } + }); + await new Promise((resolve, reject) => { + targetServer.once("error", reject); + targetServer.listen(0, "127.0.0.1", () => resolve()); + }); + targetPort = (targetServer.address() as AddressInfo).port; +}); + +afterEach(async () => { + await Promise.all([...services].map((service) => service.closeAll())); + services.clear(); + await Promise.all( + [...temporaryTargetServers].map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }), + ), + ); + temporaryTargetServers.clear(); + targetWebSocketPath = undefined; + targetWebSocketCookie = undefined; + targetWebSocketSetCookie = undefined; +}); + +afterAll(async () => { + targetWss.close(); + await new Promise((resolve) => { + targetServer.close(() => resolve()); + }); +}); + +function portalService() { + const service = createGatewayPortalService({ httpBindHosts: ["127.0.0.1"], httpServers: [] }); + services.add(service); + return service; +} + +async function listenTarget( + handler: (req: IncomingMessage, res: ServerResponse) => void, +): Promise { + const server = createServer(handler); + temporaryTargetServers.add(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + return (server.address() as AddressInfo).port; +} + +async function httpCall(params: { + port: number; + path?: string; + method?: string; + headers?: Record; + body?: string; +}): Promise { + return await new Promise((resolve, reject) => { + const req = request( + { + host: "127.0.0.1", + port: params.port, + path: params.path ?? "/", + method: params.method, + headers: params.headers, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.once("end", () => + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString("utf8"), + }), + ); + }, + ); + req.once("error", reject); + if (params.body) { + req.write(params.body); + } + req.end(); + }); +} + +function storeResponseCookies(jar: Map, result: HttpResult): void { + for (const cookie of result.headers["set-cookie"] ?? []) { + const pair = cookie.split(";", 1)[0]; + const separator = pair?.indexOf("=") ?? -1; + if (pair && separator > 0) { + jar.set(pair.slice(0, separator), pair.slice(separator + 1)); + } + } +} + +function cookieJarHeader(jar: ReadonlyMap): string { + return [...jar].map(([name, value]) => `${name}=${value}`).join("; "); +} + +function portalAuthCookie(portal: { listenPort: number; tokenQuery: string }): string { + const token = portal.tokenQuery.slice("openclaw_portal=".length); + return `openclaw_portal_${portal.listenPort}=${token}`; +} + +function webSocketMessageText(data: RawData): string { + const bytes = Array.isArray(data) + ? Buffer.concat(data) + : data instanceof ArrayBuffer + ? Buffer.from(data) + : data; + return bytes.toString("utf8"); +} + +async function browserCall( + jar: Map, + params: Omit[0], "headers">, +): Promise { + const cookie = cookieJarHeader(jar); + const result = await httpCall({ + ...params, + ...(cookie ? { headers: { Cookie: cookie } } : {}), + }); + storeResponseCookies(jar, result); + return result; +} + +describe("portal HTTP proxy", () => { + it("proxies a URL token directly, sets a private cookie, and strips the token", async () => { + const targetPaths: string[] = []; + targetHandler = (req, res) => { + targetPaths.push(req.url ?? "/"); + res.statusCode = 200; + res.end("proxied"); + }; + const portal = await portalService().open({ targetPort, title: "App" }); + + const unauthorized = await httpCall({ port: portal.listenPort }); + expect(unauthorized.status).toBe(401); + expect(unauthorized.body).toContain("This portal is private"); + expect(unauthorized.body).not.toContain(portal.tokenQuery); + + const authorized = await httpCall({ + port: portal.listenPort, + path: `/preview?x=1&${portal.tokenQuery}`, + }); + expect(authorized.status).toBe(200); + expect(authorized.body).toBe("proxied"); + expect(authorized.headers["set-cookie"]?.[0]).toContain( + `openclaw_portal_${portal.listenPort}=`, + ); + expect(authorized.headers["set-cookie"]?.[0]).toContain("HttpOnly; SameSite=Lax; Path=/"); + expect(targetPaths).toEqual(["/preview?x=1"]); + + const cookieOnly = await httpCall({ + port: portal.listenPort, + path: "/cookie?y=2", + headers: { Cookie: portalAuthCookie(portal) }, + }); + expect(cookieOnly).toMatchObject({ status: 200, body: "proxied" }); + expect(targetPaths).toEqual(["/preview?x=1", "/cookie?y=2"]); + }); + + it("keeps concurrent portal HTTP sessions authorized in A-B-A order", async () => { + targetHandler = (_req, res) => { + res.statusCode = 200; + res.end("target-a"); + }; + const targetPortB = await listenTarget((_req, res) => { + res.statusCode = 200; + res.end("target-b"); + }); + const service = portalService(); + const portalA = await service.open({ targetPort }); + const portalB = await service.open({ targetPort: targetPortB }); + const jar = new Map(); + + expect( + await browserCall(jar, { + port: portalA.listenPort, + path: `/?${portalA.tokenQuery}`, + }), + ).toMatchObject({ status: 200, body: "target-a" }); + expect( + await browserCall(jar, { + port: portalB.listenPort, + path: `/?${portalB.tokenQuery}`, + }), + ).toMatchObject({ status: 200, body: "target-b" }); + + for (const [portal, body] of [ + [portalA, "target-a"], + [portalB, "target-b"], + [portalA, "target-a"], + ] as const) { + expect(await browserCall(jar, { port: portal.listenPort })).toMatchObject({ + status: 200, + body, + }); + } + }); + + it("streams HTTP requests and responses with rewritten safe headers", async () => { + let received: + | { + host?: string; + cookie?: string; + forwardedFor?: string; + proto?: string; + forwardedHost?: string; + } + | undefined; + targetHandler = (req, res) => { + received = { + host: req.headers.host, + cookie: req.headers.cookie, + forwardedFor: req.headers["x-forwarded-for"] as string | undefined, + proto: req.headers["x-forwarded-proto"] as string | undefined, + forwardedHost: req.headers["x-forwarded-host"] as string | undefined, + }; + res.statusCode = 201; + res.setHeader("Connection", "keep-alive, x-target-hop"); + res.setHeader("Keep-Alive", "upstream-secret=17"); + res.setHeader("X-Target-Hop", "remove"); + res.setHeader("X-App", "kept"); + res.write("hello "); + res.end("portal"); + }; + const portal = await portalService().open({ targetPort }); + const result = await httpCall({ + port: portal.listenPort, + path: "/asset?q=1", + headers: { + Host: "portal.example:9999", + Cookie: `openclaw_plugin_tab=secret; ${portalAuthCookie(portal)}`, + Connection: "keep-alive, x-remove-me", + "X-Remove-Me": "remove", + }, + }); + + expect(result).toMatchObject({ status: 201, body: "hello portal" }); + expect(result.headers["x-app"]).toBe("kept"); + expect(result.headers["x-target-hop"]).toBeUndefined(); + // Node may add its own connection-local Keep-Alive header; the upstream value must not pass. + expect(result.headers["keep-alive"]).not.toBe("upstream-secret=17"); + expect(received).toMatchObject({ + host: `localhost:${targetPort}`, + proto: "http", + forwardedHost: "portal.example:9999", + }); + expect(received?.cookie).toBeUndefined(); + expect(received?.forwardedFor).toMatch(/127\.0\.0\.1|::ffff:127\.0\.0\.1/u); + }); + + it("forwards only each target's prefixed cookies, never either portal auth cookie", async () => { + const receivedCookiesA: Array = []; + targetHandler = (req, res) => { + receivedCookiesA.push(req.headers.cookie); + if (req.url === "/set") { + res.setHeader("Set-Cookie", "session=a; Domain=target.example; Path=/; HttpOnly"); + } + res.statusCode = 200; + res.end("target-a"); + }; + const receivedCookiesB: Array = []; + const targetPortB = await listenTarget((req, res) => { + receivedCookiesB.push(req.headers.cookie); + if (req.url === "/set") { + res.setHeader("Set-Cookie", "session=b; Domain=target.example; Path=/; HttpOnly"); + } + res.statusCode = 200; + res.end("target-b"); + }); + const service = portalService(); + const portalA = await service.open({ targetPort }); + const portalB = await service.open({ targetPort: targetPortB }); + const jar = new Map(); + + const initialA = await browserCall(jar, { + port: portalA.listenPort, + path: `/set?${portalA.tokenQuery}`, + }); + const initialB = await browserCall(jar, { + port: portalB.listenPort, + path: `/set?${portalB.tokenQuery}`, + }); + expect(initialA.headers["set-cookie"]).toContain( + `oc_portal_${targetPort}_session=a; Path=/; HttpOnly`, + ); + expect(initialB.headers["set-cookie"]).toContain( + `oc_portal_${targetPortB}_session=b; Path=/; HttpOnly`, + ); + expect( + [...(initialA.headers["set-cookie"] ?? []), ...(initialB.headers["set-cookie"] ?? [])].join( + "; ", + ), + ).not.toContain("Domain="); + expect([...jar.keys()].filter((name) => name.startsWith("openclaw_portal"))).toEqual([ + `openclaw_portal_${portalA.listenPort}`, + `openclaw_portal_${portalB.listenPort}`, + ]); + + expect(await browserCall(jar, { port: portalA.listenPort })).toMatchObject({ + status: 200, + body: "target-a", + }); + expect(await browserCall(jar, { port: portalB.listenPort })).toMatchObject({ + status: 200, + body: "target-b", + }); + expect(receivedCookiesA).toEqual([undefined, "session=a"]); + expect(receivedCookiesB).toEqual([undefined, "session=b"]); + }); + + it("forces no-referrer and never forwards a token-bearing referrer", async () => { + let receivedReferer: string | undefined; + targetHandler = (req, res) => { + receivedReferer = req.headers.referer; + // A hostile or careless target must not be able to widen the policy. + res.setHeader("Referrer-Policy", "unsafe-url"); + res.statusCode = 200; + res.end("proxied"); + }; + const portal = await portalService().open({ targetPort }); + const token = portal.tokenQuery.slice("openclaw_portal=".length); + + const result = await httpCall({ + port: portal.listenPort, + headers: { + Cookie: `openclaw_portal_${portal.listenPort}=${token}`, + Referer: `http://127.0.0.1:${portal.listenPort}/?${portal.tokenQuery}`, + }, + }); + + expect(result.status).toBe(200); + expect(result.headers["referrer-policy"]).toBe("no-referrer"); + expect(receivedReferer).toBeUndefined(); + + const unauthorized = await httpCall({ port: portal.listenPort }); + expect(unauthorized.headers["referrer-policy"]).toBe("no-referrer"); + }); + + it("streams POST bodies to the target", async () => { + let body = ""; + targetHandler = (req, res) => { + req.setEncoding("utf8"); + req.on("data", (chunk: string) => (body += chunk)); + req.once("end", () => { + res.statusCode = 204; + res.end(); + }); + }; + const portal = await portalService().open({ targetPort }); + const result = await httpCall({ + port: portal.listenPort, + method: "POST", + headers: { + Cookie: portalAuthCookie(portal), + "Content-Type": "text/plain", + }, + body: "streamed request", + }); + + expect(result.status).toBe(204); + expect(body).toBe("streamed request"); + }); + + it("shows a retry page while the target is down", async () => { + const unavailableTarget = createServer(); + await new Promise((resolve) => { + unavailableTarget.listen(0, "127.0.0.1", resolve); + }); + const port = (unavailableTarget.address() as AddressInfo).port; + await new Promise((resolve) => { + unavailableTarget.close(() => resolve()); + }); + const portal = await portalService().open({ targetPort: port }); + + const result = await httpCall({ + port: portal.listenPort, + headers: { Cookie: portalAuthCookie(portal) }, + }); + expect(result.status).toBe(502); + expect(result.body).toContain(`Waiting for the app on port ${port}…`); + expect(result.body).toContain('http-equiv="refresh" content="2"'); + }); + + it("reaches IPv6-only targets through the localhost dual-stack dial", async () => { + // Node >=17 dev servers (Vite, Next.js) often bind ::1 only on "localhost". + const v6Target = createServer((req, res) => { + res.statusCode = 200; + res.end("v6 proxied"); + }); + await new Promise((resolve, reject) => { + v6Target.once("error", reject); + v6Target.listen(0, "::1", () => resolve()); + }); + try { + const v6Port = (v6Target.address() as AddressInfo).port; + const portal = await portalService().open({ targetPort: v6Port }); + const result = await httpCall({ + port: portal.listenPort, + path: `/?${portal.tokenQuery}`, + }); + expect(result).toMatchObject({ status: 200, body: "v6 proxied" }); + } finally { + await new Promise((resolve) => { + v6Target.close(() => resolve()); + }); + } + }); + + it("splices WebSockets and destroys upgraded sockets and listeners on close", async () => { + const service = portalService(); + const portal = await service.open({ targetPort }); + targetWebSocketSetCookie = "socket=ready; Domain=target.example; Path=/; HttpOnly"; + let upgradeCookies: string[] | undefined; + const ws = new WebSocket( + `ws://127.0.0.1:${portal.listenPort}/hmr?channel=dev&${portal.tokenQuery}`, + { headers: { Cookie: "openclaw_plugin_tab=secret" } }, + ); + ws.once("upgrade", (response) => { + upgradeCookies = response.headers["set-cookie"]; + }); + await new Promise((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", reject); + }); + const echoed = new Promise((resolve) => { + ws.once("message", (data) => resolve(webSocketMessageText(data))); + }); + ws.send("hot reload"); + expect(await echoed).toBe("hot reload"); + expect(targetWebSocketPath).toBe("/hmr?channel=dev"); + expect(targetWebSocketCookie).toBeUndefined(); + expect(upgradeCookies).toEqual([`oc_portal_${targetPort}_socket=ready; Path=/; HttpOnly`]); + + const closed = new Promise((resolve) => { + ws.once("close", () => resolve()); + }); + await service.close(portal.id); + await closed; + await expect(httpCall({ port: portal.listenPort })).rejects.toThrow(); + }); + + it("keeps portal A WebSocket authorized after portal B replaces the active URL", async () => { + targetHandler = (_req, res) => { + res.statusCode = 200; + res.end("target-a"); + }; + const targetPortB = await listenTarget((_req, res) => { + res.statusCode = 200; + res.end("target-b"); + }); + const service = portalService(); + const portalA = await service.open({ targetPort }); + const portalB = await service.open({ targetPort: targetPortB }); + const jar = new Map(); + await browserCall(jar, { + port: portalA.listenPort, + path: `/?${portalA.tokenQuery}`, + }); + await browserCall(jar, { + port: portalB.listenPort, + path: `/?${portalB.tokenQuery}`, + }); + + const ws = new WebSocket(`ws://127.0.0.1:${portalA.listenPort}/hmr?channel=dev`, { + headers: { Cookie: cookieJarHeader(jar) }, + }); + await new Promise((resolve, reject) => { + ws.once("open", resolve); + ws.once("error", reject); + }); + const echoed = new Promise((resolve) => { + ws.once("message", (data) => resolve(webSocketMessageText(data))); + }); + ws.send("portal-a"); + expect(await echoed).toBe("portal-a"); + expect(targetWebSocketPath).toBe("/hmr?channel=dev"); + await new Promise((resolve) => { + ws.once("close", () => resolve()); + ws.close(); + }); + }); +}); diff --git a/src/gateway/portals/portal-http-proxy.ts b/src/gateway/portals/portal-http-proxy.ts new file mode 100644 index 000000000000..960729d99efc --- /dev/null +++ b/src/gateway/portals/portal-http-proxy.ts @@ -0,0 +1,420 @@ +import { timingSafeEqual } from "node:crypto"; +import type { + IncomingHttpHeaders, + IncomingMessage, + OutgoingHttpHeaders, + ServerResponse, +} from "node:http"; +import { request as requestHttp } from "node:http"; +import net, { type Socket } from "node:net"; +import type { Duplex } from "node:stream"; + +const PORTAL_AUTH_NAME = "openclaw_portal"; +// Browser cookie jars are hostname-scoped, so the stable listener port in the +// auth cookie name keeps concurrently open portals from replacing each other. +function portalAuthCookieName(listenPort: number): string { + return `${PORTAL_AUTH_NAME}_${listenPort}`; +} + +// Cookies are hostname-scoped, not port-scoped. Per-target prefixes keep Gateway +// and sibling portal cookies from leaking into an agent-run application. +const PORTAL_COOKIE_PREFIX = "oc_portal_"; +// The portal URL carries the bearer token in its query, so the browser must never +// attach it as a Referer. The target controls its own response headers, so this is +// forced after upstream headers are copied rather than merely defaulted. +const PORTAL_REFERRER_POLICY = "no-referrer"; +const MAX_WEBSOCKET_RESPONSE_HEADER_BYTES = 64 * 1024; +const HOP_BY_HOP_HEADERS = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +type PortalProxyTarget = { + listenPort: number; + targetPort: number; + token: string; +}; + +type PortalAuthorization = + | { kind: "authorized"; requestPath: string; setCookie: boolean } + | { kind: "unauthorized" }; + +function tokensEqual(candidate: string | undefined, expected: string): boolean { + if (!candidate) { + return false; + } + const candidateBytes = Buffer.from(candidate); + const expectedBytes = Buffer.from(expected); + return ( + candidateBytes.length === expectedBytes.length && timingSafeEqual(candidateBytes, expectedBytes) + ); +} + +function readPortalCookie( + cookieHeader: string | undefined, + listenPort: number, +): string | undefined { + const authCookieName = portalAuthCookieName(listenPort); + for (const segment of cookieHeader?.split(";") ?? []) { + const separator = segment.indexOf("="); + if (separator < 0 || segment.slice(0, separator).trim() !== authCookieName) { + continue; + } + return segment.slice(separator + 1).trim(); + } + return undefined; +} + +function portalCookiePrefix(targetPort: number): string { + return `${PORTAL_COOKIE_PREFIX}${targetPort}_`; +} + +function readTargetCookies( + cookieHeader: string | undefined, + targetPort: number, +): string | undefined { + const prefix = portalCookiePrefix(targetPort); + const retained = (cookieHeader?.split(";") ?? []).flatMap((segment) => { + const separator = segment.indexOf("="); + if (separator <= 0) { + return []; + } + const name = segment.slice(0, separator).trim(); + if (!name.startsWith(prefix) || name.length === prefix.length) { + return []; + } + return [`${name.slice(prefix.length)}=${segment.slice(separator + 1).trim()}`]; + }); + const normalized = retained.join("; "); + return normalized || undefined; +} + +function rewriteTargetCookie(cookie: string, targetPort: number): string | undefined { + const [cookiePair, ...attributes] = cookie.split(";"); + const separator = cookiePair?.indexOf("=") ?? -1; + if (!cookiePair || separator <= 0) { + return undefined; + } + const name = cookiePair.slice(0, separator).trim(); + if (!name) { + return undefined; + } + const retainedAttributes = attributes.filter((attribute) => !/^\s*domain\s*=/iu.test(attribute)); + const suffix = retainedAttributes.length > 0 ? `;${retainedAttributes.join(";")}` : ""; + return `${portalCookiePrefix(targetPort)}${name}=${cookiePair.slice(separator + 1)}${suffix}`; +} + +function parsePortalUrl(req: IncomingMessage): URL | undefined { + try { + return new URL(req.url ?? "/", "http://openclaw.invalid"); + } catch { + return undefined; + } +} + +function authorizePortalRequest( + req: IncomingMessage, + target: PortalProxyTarget, +): PortalAuthorization { + const url = parsePortalUrl(req); + const queryToken = url?.searchParams.get(PORTAL_AUTH_NAME) ?? undefined; + if (tokensEqual(queryToken, target.token)) { + url?.searchParams.delete(PORTAL_AUTH_NAME); + return { + kind: "authorized", + requestPath: `${url?.pathname ?? "/"}${url?.search ?? ""}`, + setCookie: true, + }; + } + if (tokensEqual(readPortalCookie(req.headers.cookie, target.listenPort), target.token)) { + url?.searchParams.delete(PORTAL_AUTH_NAME); + return { + kind: "authorized", + requestPath: `${url?.pathname ?? "/"}${url?.search ?? ""}`, + setCookie: false, + }; + } + return { kind: "unauthorized" }; +} + +function portalCookie(target: PortalProxyTarget, tls: boolean): string { + return `${portalAuthCookieName(target.listenPort)}=${target.token}; HttpOnly; SameSite=Lax; Path=/${tls ? "; Secure" : ""}`; +} + +function setProxyResponseHeader( + res: ServerResponse, + name: string, + value: string | string[] | number, + targetPort: number, +): void { + if (name !== "set-cookie") { + res.setHeader(name, value); + return; + } + const existing = res.getHeader("Set-Cookie"); + const existingCookies = + existing === undefined ? [] : Array.isArray(existing) ? existing : [existing]; + const targetCookies = Array.isArray(value) ? value : [String(value)]; + const rewrittenCookies = targetCookies.flatMap((cookie) => { + const rewritten = rewriteTargetCookie(cookie, targetPort); + return rewritten ? [rewritten] : []; + }); + const cookies = [...existingCookies.map(String), ...rewrittenCookies]; + if (cookies.length > 0) { + res.setHeader("Set-Cookie", cookies); + } +} + +function htmlResponse( + res: ServerResponse, + statusCode: number, + html: string, + headOnly: boolean, +): void { + res.statusCode = statusCode; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Referrer-Policy", PORTAL_REFERRER_POLICY); + res.setHeader("Content-Length", String(Buffer.byteLength(html))); + res.end(headOnly ? undefined : html); +} + +function respondPortalUnauthorized(req: IncomingMessage, res: ServerResponse): void { + const html = + "Private portal" + + "

This portal is private. Open it from the OpenClaw Control UI.

"; + htmlResponse(res, 401, html, req.method === "HEAD"); +} + +function respondPortalWaiting(req: IncomingMessage, res: ServerResponse, targetPort: number): void { + const html = + '' + + `Waiting for app

Waiting for the app on port ${targetPort}…

`; + htmlResponse(res, 502, html, req.method === "HEAD"); +} + +function connectionHeaderTokens(headers: IncomingHttpHeaders): Set { + const value = headers.connection; + const joined = Array.isArray(value) ? value.join(",") : value; + return new Set( + (joined ?? "") + .split(",") + .map((token) => token.trim().toLowerCase()) + .filter(Boolean), + ); +} + +function proxyHeaders(headers: IncomingHttpHeaders, targetPort?: number): OutgoingHttpHeaders { + const result: OutgoingHttpHeaders = {}; + const connectionTokens = connectionHeaderTokens(headers); + for (const [name, value] of Object.entries(headers)) { + const normalized = name.toLowerCase(); + if ( + value === undefined || + HOP_BY_HOP_HEADERS.has(normalized) || + connectionTokens.has(normalized) + ) { + continue; + } + if (normalized === "cookie" && targetPort !== undefined) { + const cookie = readTargetCookies(Array.isArray(value) ? value.join("; ") : value, targetPort); + if (cookie) { + result.cookie = cookie; + } + continue; + } + // A referrer that still carries the bearer query would hand the target the + // credential it is being kept away from; drop it rather than forward it. + if (normalized === "referer" && String(value).includes(`${PORTAL_AUTH_NAME}=`)) { + continue; + } + result[normalized] = value; + } + return result; +} + +/** Proxies one authorized portal request only to the loopback target. */ +export function handlePortalProxyRequest(params: { + req: IncomingMessage; + res: ServerResponse; + target: PortalProxyTarget; + tls: boolean; +}): void { + const { req, res, target, tls } = params; + const authorization = authorizePortalRequest(req, target); + if (authorization.kind === "unauthorized") { + respondPortalUnauthorized(req, res); + return; + } + if (authorization.setCookie) { + res.setHeader("Set-Cookie", portalCookie(target, tls)); + } + + const headers = proxyHeaders(req.headers, target.targetPort); + const originalHost = req.headers.host; + headers.host = `localhost:${target.targetPort}`; + headers["x-forwarded-for"] = req.socket.remoteAddress ?? ""; + headers["x-forwarded-proto"] = tls ? "https" : "http"; + if (originalHost) { + headers["x-forwarded-host"] = originalHost; + } + // Dial "localhost", not a fixed loopback literal: Node >=17 dev servers (Vite, + // Next.js) often bind ::1 only, and family autoselection reaches either stack. + const proxyReq = requestHttp({ + hostname: "localhost", + createConnection: () => + net.connect({ host: "localhost", autoSelectFamily: true, port: target.targetPort }), + port: target.targetPort, + method: req.method, + path: authorization.requestPath, + headers, + }); + proxyReq.once("response", (proxyRes) => { + for (const [name, value] of Object.entries(proxyHeaders(proxyRes.headers))) { + if (value !== undefined) { + setProxyResponseHeader(res, name, value, target.targetPort); + } + } + // Overwrite, never default: a target answering with `unsafe-url` would otherwise + // send the token-bearing portal URL to every third-party origin it references. + res.setHeader("Referrer-Policy", PORTAL_REFERRER_POLICY); + res.statusCode = proxyRes.statusCode ?? 502; + proxyRes.pipe(res); + }); + proxyReq.once("error", () => { + if (!res.headersSent) { + respondPortalWaiting(req, res, target.targetPort); + } else { + res.destroy(); + } + }); + req.once("aborted", () => proxyReq.destroy()); + req.pipe(proxyReq); +} + +function websocketHeaders(req: IncomingMessage, targetPort: number, requestPath: string): string { + const lines = [`${req.method ?? "GET"} ${requestPath} HTTP/1.1`]; + for (const [name, value] of Object.entries(req.headers)) { + const normalized = name.toLowerCase(); + if ( + value === undefined || + normalized === "host" || + (HOP_BY_HOP_HEADERS.has(normalized) && + normalized !== "connection" && + normalized !== "upgrade") + ) { + continue; + } + if (normalized === "cookie") { + const cookie = readTargetCookies(Array.isArray(value) ? value.join("; ") : value, targetPort); + if (cookie) { + lines.push(`cookie: ${cookie}`); + } + continue; + } + if (normalized === "referer" && String(value).includes(`${PORTAL_AUTH_NAME}=`)) { + continue; + } + for (const item of Array.isArray(value) ? value : [value]) { + lines.push(`${normalized}: ${item}`); + } + } + lines.push(`host: localhost:${targetPort}`, "", ""); + return lines.join("\r\n"); +} + +function rejectPortalUpgrade(socket: Duplex): void { + socket.end( + "HTTP/1.1 401 Unauthorized\r\nContent-Type: text/plain; charset=utf-8\r\n" + + "Content-Length: 12\r\nConnection: close\r\n\r\nUnauthorized", + ); +} + +function forwardWebSocketResponse( + targetSocket: Socket, + browserSocket: Duplex, + targetPort: number, +): void { + let pending = Buffer.alloc(0); + const onData = (chunk: Buffer) => { + pending = Buffer.concat([pending, chunk]); + const headerEnd = pending.indexOf("\r\n\r\n"); + if (headerEnd < 0) { + if (pending.length > MAX_WEBSOCKET_RESPONSE_HEADER_BYTES) { + targetSocket.destroy(); + browserSocket.destroy(); + } + return; + } + + targetSocket.off("data", onData); + const headerLines = pending.subarray(0, headerEnd).toString("latin1").split("\r\n"); + const rewrittenLines = headerLines.flatMap((line) => { + const separator = line.indexOf(":"); + if (separator <= 0 || line.slice(0, separator).trim().toLowerCase() !== "set-cookie") { + return [line]; + } + const rewritten = rewriteTargetCookie(line.slice(separator + 1).trimStart(), targetPort); + return rewritten ? [`${line.slice(0, separator)}: ${rewritten}`] : []; + }); + browserSocket.write(`${rewrittenLines.join("\r\n")}\r\n\r\n`); + const remainder = pending.subarray(headerEnd + 4); + if (remainder.length > 0) { + browserSocket.write(remainder); + } + targetSocket.pipe(browserSocket); + }; + targetSocket.on("data", onData); +} + +/** Splices an authorized portal WebSocket upgrade into the loopback target. */ +export function handlePortalProxyUpgrade(params: { + req: IncomingMessage; + socket: Duplex; + head: Buffer; + target: PortalProxyTarget; + upgradedSockets: Set; +}): void { + const { req, socket, head, target, upgradedSockets } = params; + const authorization = authorizePortalRequest(req, target); + if (authorization.kind !== "authorized") { + rejectPortalUpgrade(socket); + return; + } + + // Same localhost/dual-stack contract as the HTTP path above. + const targetSocket: Socket = net.connect({ + host: "localhost", + autoSelectFamily: true, + port: target.targetPort, + }); + upgradedSockets.add(socket); + upgradedSockets.add(targetSocket); + const release = (stream: Duplex) => upgradedSockets.delete(stream); + socket.once("close", () => { + release(socket); + targetSocket.destroy(); + }); + targetSocket.once("close", () => { + release(targetSocket); + socket.destroy(); + }); + socket.once("error", () => targetSocket.destroy()); + targetSocket.once("error", () => socket.destroy()); + targetSocket.once("connect", () => { + forwardWebSocketResponse(targetSocket, socket, target.targetPort); + targetSocket.write(websocketHeaders(req, target.targetPort, authorization.requestPath)); + if (head.length > 0) { + targetSocket.write(head); + } + socket.pipe(targetSocket); + }); +} diff --git a/src/gateway/portals/portal-service.test.ts b/src/gateway/portals/portal-service.test.ts new file mode 100644 index 000000000000..7abb0d107531 --- /dev/null +++ b/src/gateway/portals/portal-service.test.ts @@ -0,0 +1,112 @@ +import { request } from "node:http"; +import net from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { createGatewayPortalService, type GatewayPortalService } from "./portal-service.js"; + +const services = new Set(); + +afterEach(async () => { + await Promise.all([...services].map((service) => service.closeAll())); + services.clear(); +}); + +function makeService(hosts: string[]) { + const httpServers: import("node:http").Server[] = []; + const service = createGatewayPortalService({ httpBindHosts: hosts, httpServers }); + services.add(service); + return { service, httpServers }; +} + +async function getStatus(host: string, port: number, path: string): Promise { + return await new Promise((resolve, reject) => { + const req = request({ host, port, path }, (res) => { + res.resume(); + res.once("end", () => resolve(res.statusCode ?? 0)); + }); + req.once("error", reject); + req.end(); + }); +} + +async function expectConnectionRefused(port: number): Promise { + await new Promise((resolve, reject) => { + const socket = net.connect({ host: "127.0.0.1", port }); + socket.once("connect", () => { + socket.destroy(); + reject(new Error(`listener ${port} remained open`)); + }); + socket.once("error", () => resolve()); + }); +} + +describe("gateway portal service", () => { + it("allocates one port across every frozen bind host", async () => { + const { service, httpServers } = makeService(["127.0.0.1", "::1"]); + const portal = await service.open({ targetPort: 3000, title: "App" }); + + expect(portal).toMatchObject({ id: "p3000", port: 3000, title: "App" }); + expect(portal.listenPort).toBeGreaterThan(0); + expect(httpServers).toHaveLength(2); + expect(await getStatus("127.0.0.1", portal.listenPort, "/")).toBe(401); + expect(await getStatus("::1", portal.listenPort, "/")).toBe(401); + }); + + it("updates an existing target without replacing its listener or token", async () => { + const { service, httpServers } = makeService(["127.0.0.1"]); + const first = await service.open({ targetPort: 3000, title: "First" }); + const second = await service.open({ + targetPort: 3000, + title: "Second", + description: "Updated", + path: "/preview", + }); + + expect(second).toMatchObject({ + id: first.id, + listenPort: first.listenPort, + tokenQuery: first.tokenQuery, + title: "Second", + description: "Updated", + path: "/preview", + publicUrl: `http://127.0.0.1:${first.listenPort}/preview`, + }); + expect(second.url).toBe(`${second.publicUrl}?${second.tokenQuery}`); + expect(httpServers).toHaveLength(1); + expect(service.list()).toEqual([second]); + }); + + it("closes idempotently and closes every portal on shutdown", async () => { + const { service, httpServers } = makeService(["127.0.0.1"]); + const first = await service.open({ targetPort: 3000 }); + const second = await service.open({ targetPort: 4000 }); + + await service.close(first.id); + await service.close(first.id); + expect(service.list().map((entry) => entry.id)).toEqual([second.id]); + await expectConnectionRefused(first.listenPort); + + await service.closeAll(); + expect(service.list()).toEqual([]); + expect(httpServers).toEqual([]); + await expectConnectionRefused(second.listenPort); + }); + + it("removes every registered listener after a partial bind failure", async () => { + const { service, httpServers } = makeService(["127.0.0.1", "127.0.0.1"]); + + await expect(service.open({ targetPort: 3000 })).rejects.toThrow(/already listening/u); + expect(service.list()).toEqual([]); + expect(httpServers).toEqual([]); + }); + + it.each([ + ["0.0.0.0", "127.0.0.1"], + ["::", "[::1]"], + ])("maps wildcard bind host %s to openable host %s", async (bindHost, openableHost) => { + const { service } = makeService([bindHost]); + const portal = await service.open({ targetPort: 3000 }); + + expect(portal.publicUrl).toBe(`http://${openableHost}:${portal.listenPort}/`); + expect(portal.url).toBe(`${portal.publicUrl}?${portal.tokenQuery}`); + }); +}); diff --git a/src/gateway/portals/portal-service.ts b/src/gateway/portals/portal-service.ts new file mode 100644 index 000000000000..09bf8f5af711 --- /dev/null +++ b/src/gateway/portals/portal-service.ts @@ -0,0 +1,237 @@ +import { randomBytes } from "node:crypto"; +import { createServer as createHttpServer, type Server as HttpServer } from "node:http"; +import { createServer as createHttpsServer } from "node:https"; +import type { AddressInfo } from "node:net"; +import type { Duplex } from "node:stream"; +import type { TlsOptions } from "node:tls"; +import type { + PortalOpenResult, + PortalSummary, +} from "../../../packages/gateway-protocol/src/index.js"; +import { listenGatewayHttpServer } from "../server/http-listen.js"; +import { handlePortalProxyRequest, handlePortalProxyUpgrade } from "./portal-http-proxy.js"; + +type PortalEntry = { + id: string; + title: string; + description?: string; + path?: string; + targetPort: number; + token: string; + listenPort: number; + createdAtMs: number; +}; + +type PortalRuntimeEntry = { + portal: PortalEntry; + servers: HttpServer[]; + upgradedSockets: Set; +}; + +type GatewayPortalOpenParams = { + targetPort: number; + title?: string; + description?: string; + path?: string; +}; + +export type GatewayPortalService = { + open: (params: GatewayPortalOpenParams) => Promise; + list: () => PortalSummary[]; + close: (id: string) => Promise; + closeAll: () => Promise; +}; + +function removeServers(shared: HttpServer[], owned: readonly HttpServer[]): void { + for (const server of owned) { + const index = shared.indexOf(server); + if (index >= 0) { + shared.splice(index, 1); + } + } +} + +async function closeServers(servers: readonly HttpServer[]): Promise { + await Promise.all( + servers.map( + (server) => + new Promise((resolve) => { + if (!server.listening) { + resolve(); + return; + } + server.close(() => resolve()); + server.closeAllConnections(); + }), + ), + ); +} + +function formatPortalHost(host: string): string { + const openableHost = host === "0.0.0.0" ? "127.0.0.1" : host === "::" ? "::1" : host; + return openableHost.includes(":") ? `[${openableHost}]` : openableHost; +} + +/** Creates the gateway-lifetime registry and per-portal transport listeners. */ +export function createGatewayPortalService(params: { + httpBindHosts: readonly string[]; + tlsOptions?: TlsOptions; + httpServers: HttpServer[]; +}): GatewayPortalService { + const entries = new Map(); + const operations = new Map>(); + let closed = false; + + const summarize = (portal: PortalEntry): PortalOpenResult => { + const host = params.httpBindHosts[0]; + if (!host) { + throw new Error("Gateway listener must start before opening a portal"); + } + const scheme = params.tlsOptions ? "https" : "http"; + const tokenQuery = `openclaw_portal=${portal.token}`; + const publicUrl = `${scheme}://${formatPortalHost(host)}:${portal.listenPort}${portal.path ?? "/"}`; + const openableUrl = new URL(publicUrl); + openableUrl.searchParams.set("openclaw_portal", portal.token); + return { + id: portal.id, + title: portal.title, + port: portal.targetPort, + listenPort: portal.listenPort, + tokenQuery, + url: openableUrl.toString(), + publicUrl, + ...(portal.path ? { path: portal.path } : {}), + ...(portal.description ? { description: portal.description } : {}), + createdAtMs: portal.createdAtMs, + }; + }; + + const serialize = async (id: string, operation: () => Promise): Promise => { + const previous = operations.get(id) ?? Promise.resolve(); + const result = previous.then(operation, operation); + const completion = result.then( + () => undefined, + () => undefined, + ); + operations.set(id, completion); + try { + return await result; + } finally { + if (operations.get(id) === completion) { + operations.delete(id); + } + } + }; + + const closeEntry = async (id: string): Promise => { + const runtime = entries.get(id); + if (!runtime) { + return; + } + // Remove authority before asynchronous teardown so no request can rediscover a closing portal. + entries.delete(id); + removeServers(params.httpServers, runtime.servers); + for (const socket of runtime.upgradedSockets) { + socket.destroy(); + } + runtime.upgradedSockets.clear(); + await closeServers(runtime.servers); + }; + + return { + open: async (input) => { + const id = `p${input.targetPort}`; + return await serialize(id, async () => { + if (closed) { + throw new Error("portals unavailable"); + } + const existing = entries.get(id); + if (existing) { + existing.portal.title = input.title?.trim() || existing.portal.title; + if (input.description !== undefined) { + existing.portal.description = input.description; + } + if (input.path !== undefined) { + existing.portal.path = input.path; + } + return summarize(existing.portal); + } + if (params.httpBindHosts.length === 0) { + throw new Error("Gateway listener must start before opening a portal"); + } + + const portal: PortalEntry = { + id, + title: input.title?.trim() || `Port ${input.targetPort}`, + ...(input.description ? { description: input.description } : {}), + ...(input.path ? { path: input.path } : {}), + targetPort: input.targetPort, + token: randomBytes(32).toString("hex"), + listenPort: 0, + createdAtMs: Date.now(), + }; + const upgradedSockets = new Set(); + const handler = ( + req: import("node:http").IncomingMessage, + res: import("node:http").ServerResponse, + ) => + handlePortalProxyRequest({ req, res, target: portal, tls: Boolean(params.tlsOptions) }); + const servers = params.httpBindHosts.map(() => + params.tlsOptions + ? createHttpsServer(params.tlsOptions, handler) + : createHttpServer(handler), + ); + for (const server of servers) { + server.on("upgrade", (req, socket, head) => + handlePortalProxyUpgrade({ req, socket, head, target: portal, upgradedSockets }), + ); + } + // Registration precedes every bind so whole-gateway cleanup owns partial startup. + params.httpServers.push(...servers); + try { + for (const [index, host] of params.httpBindHosts.entries()) { + const server = servers[index]; + if (!server) { + throw new Error(`Missing portal HTTP server for bind host ${host}`); + } + await listenGatewayHttpServer({ + httpServer: server, + bindHost: host, + port: index === 0 ? 0 : portal.listenPort, + retryEaddrinuse: false, + serviceName: "portal", + endpointScheme: params.tlsOptions ? "https" : "http", + }); + if (index === 0) { + const address = server.address() as AddressInfo | null; + if (!address || typeof address === "string") { + throw new Error("Portal listener failed to resolve its port"); + } + portal.listenPort = address.port; + } + } + } catch (error) { + removeServers(params.httpServers, servers); + await closeServers(servers); + throw error; + } + entries.set(id, { portal, servers, upgradedSockets }); + return summarize(portal); + }); + }, + list: () => + [...entries.values()] + .map(({ portal }) => summarize(portal)) + .toSorted( + (left, right) => left.createdAtMs - right.createdAtMs || left.id.localeCompare(right.id), + ), + close: async (id) => { + await serialize(id, () => closeEntry(id)); + }, + closeAll: async () => { + closed = true; + const ids = new Set([...entries.keys(), ...operations.keys()]); + await Promise.all([...ids].map((id) => serialize(id, () => closeEntry(id)))); + }, + }; +} diff --git a/src/gateway/server-broadcast.ts b/src/gateway/server-broadcast.ts index 9679606f64d3..fc18803a364f 100644 --- a/src/gateway/server-broadcast.ts +++ b/src/gateway/server-broadcast.ts @@ -85,6 +85,7 @@ const EVENT_SCOPE_GUARDS: Record = { // methods; also targeted to the owning connection at broadcast time. "terminal.data": [ADMIN_SCOPE], "terminal.exit": [ADMIN_SCOPE], + "portal.changed": [READ_SCOPE], }; // Opt-in scoped clients never receive session-bearing broadcasts without an diff --git a/src/gateway/server-kernel-request-runtime.ts b/src/gateway/server-kernel-request-runtime.ts index 587d2257b120..737cd937e455 100644 --- a/src/gateway/server-kernel-request-runtime.ts +++ b/src/gateway/server-kernel-request-runtime.ts @@ -31,6 +31,7 @@ export async function prepareGatewayKernelRequestRuntime(params: { sessionObserver, getMcpAppSandboxPort, ensureSandboxHostPort, + getPortalService, terminalLaunchPolicy, execApprovalManager, cancelRunBoundApprovals, @@ -118,6 +119,7 @@ export async function prepareGatewayKernelRequestRuntime(params: { sessionObserver, getMcpAppSandboxPort, ensureSandboxHostPort, + getPortalService, resolveTerminalLaunchPolicy: terminalLaunchPolicy.resolve, isTerminalEnabled: terminalLaunchPolicy.isEnabled, execApprovalManager, diff --git a/src/gateway/server-lifecycle.ts b/src/gateway/server-lifecycle.ts index 8ba899d3a8a0..67080fadebbb 100644 --- a/src/gateway/server-lifecycle.ts +++ b/src/gateway/server-lifecycle.ts @@ -536,6 +536,7 @@ export async function prepareGatewayLifecycle(params: { const { createGatewayCloseHandler, drainActiveSessionsForShutdown } = await loadGatewayCloseModule(); const transport = transportBridge.current(); + await transport?.portalService.closeAll(); await createGatewayCloseHandler({ bonjourStop: runtimeState.bonjourStop, tailscaleCleanup: runtimeState.tailscaleCleanup, diff --git a/src/gateway/server-methods-list.test.ts b/src/gateway/server-methods-list.test.ts index 306df4ec9ec0..22687931f6d8 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( @@ -222,7 +229,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", @@ -280,6 +287,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); @@ -313,6 +323,9 @@ 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..f55a09fb638a 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -83,4 +83,5 @@ export const GATEWAY_EVENTS = [ "terminal.data", "terminal.exit", GATEWAY_EVENT_UPDATE_AVAILABLE, + "portal.changed", ]; diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 44486ab0a5c2..71251c066ab3 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -126,6 +126,7 @@ const CORE_GATEWAY_HANDLER_MODULES = { import("./server-methods/plugin-host-hooks.js").then((module) => module.pluginHostHookHandlers), plugins: () => import("./server-methods/plugins.js").then((module) => module.pluginsHandlers), projects: () => import("./server-methods/projects.js").then((module) => module.projectsHandlers), + portals: () => import("./server-methods/portals.js").then((module) => module.portalHandlers), migrations: () => import("./server-methods/migrations.js").then((module) => module.migrationsHandlers), push: () => import("./server-methods/push.js").then((module) => module.pushHandlers), diff --git a/src/gateway/server-methods/portals.test.ts b/src/gateway/server-methods/portals.test.ts new file mode 100644 index 000000000000..f5c9028d98b9 --- /dev/null +++ b/src/gateway/server-methods/portals.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + PortalOpenResult, + PortalSummary, +} from "../../../packages/gateway-protocol/src/index.js"; +import { resolveCoreOperatorGatewayMethodScope } from "../methods/core-descriptors.js"; +import type { GatewayPortalService } from "../portals/portal-service.js"; +import { createGatewayBroadcaster } from "../server-broadcast.js"; +import type { GatewayWsClient } from "../server/ws-types.js"; +import { portalHandlers } from "./portals.js"; + +const portal = { + id: "p3000", + title: "App", + port: 3000, + listenPort: 43123, + tokenQuery: `openclaw_portal=${"a".repeat(64)}`, + url: `http://127.0.0.1:43123/?openclaw_portal=${"a".repeat(64)}`, + publicUrl: "http://127.0.0.1:43123/", + createdAtMs: 1, +} satisfies PortalOpenResult; + +function harness(service?: GatewayPortalService, scopes = ["operator.write"]) { + const broadcast = vi.fn(); + const invoke = async (method: keyof typeof portalHandlers, params: Record) => { + const respond = vi.fn(); + await portalHandlers[method]!({ + params, + respond, + client: { connect: { scopes } } as never, + context: { portalService: service, broadcast } as never, + } as never); + return respond; + }; + return { broadcast, invoke }; +} + +describe("portal gateway methods", () => { + it("registers list and mutations with least-privilege scopes", () => { + expect(resolveCoreOperatorGatewayMethodScope("portal.list")).toBe("operator.read"); + expect(resolveCoreOperatorGatewayMethodScope("portal.open")).toBe("operator.write"); + expect(resolveCoreOperatorGatewayMethodScope("portal.close")).toBe("operator.write"); + }); + + it("round-trips list, open, and idempotent close with replace-set broadcasts", async () => { + let portals: PortalSummary[] = []; + const service: GatewayPortalService = { + list: () => portals, + open: vi.fn(async () => { + portals = [portal]; + return portal; + }), + close: vi.fn(async () => { + portals = []; + }), + closeAll: vi.fn(async () => {}), + }; + const { invoke, broadcast } = harness(service); + + expect((await invoke("portal.list", {})).mock.calls[0]).toEqual([ + true, + { portals: [] }, + undefined, + ]); + expect((await invoke("portal.open", { port: 3000, title: "App" })).mock.calls[0]).toEqual([ + true, + portal, + undefined, + ]); + expect(service.open).toHaveBeenCalledWith({ targetPort: 3000, title: "App" }); + expect(broadcast).toHaveBeenLastCalledWith( + "portal.changed", + { + portals: [ + { + id: portal.id, + title: portal.title, + port: portal.port, + listenPort: portal.listenPort, + publicUrl: portal.publicUrl, + createdAtMs: portal.createdAtMs, + }, + ], + }, + { dropIfSlow: true }, + ); + expect((await invoke("portal.close", { id: "missing" })).mock.calls[0]).toEqual([ + true, + { closed: true }, + undefined, + ]); + expect(broadcast).toHaveBeenLastCalledWith( + "portal.changed", + { portals: [] }, + { dropIfSlow: true }, + ); + }); + + it("returns portal credentials only to write-capable operators", async () => { + const service: GatewayPortalService = { + list: () => [portal], + open: vi.fn(), + close: vi.fn(), + closeAll: vi.fn(), + }; + + const readResponse = await harness(service, ["operator.read"]).invoke("portal.list", {}); + expect(readResponse.mock.calls[0]?.[1]).toEqual({ + portals: [ + { + id: portal.id, + title: portal.title, + port: portal.port, + listenPort: portal.listenPort, + publicUrl: portal.publicUrl, + createdAtMs: portal.createdAtMs, + }, + ], + }); + + const writeResponse = await harness(service, ["operator.write"]).invoke("portal.list", {}); + expect(writeResponse.mock.calls[0]?.[1]).toEqual({ portals: [portal] }); + + const adminResponse = await harness(service, ["operator.admin"]).invoke("portal.list", {}); + expect(adminResponse.mock.calls[0]?.[1]).toEqual({ portals: [portal] }); + }); + + it("rejects malformed requests before service access and reports absent transports", async () => { + const service: GatewayPortalService = { + list: vi.fn(() => []), + open: vi.fn(), + close: vi.fn(), + closeAll: vi.fn(), + }; + const invalid = await harness(service).invoke("portal.open", { port: 0 }); + expect(invalid).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + expect(service.open).not.toHaveBeenCalled(); + + const unavailable = await harness().invoke("portal.list", {}); + expect(unavailable).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST", message: "portals unavailable" }), + ); + }); + + it("returns Error messages without the Error prefix", async () => { + const service: GatewayPortalService = { + list: () => [], + open: vi.fn(async () => { + throw new Error("portal bind failed"); + }), + close: vi.fn(async () => {}), + closeAll: vi.fn(async () => {}), + }; + + const response = await harness(service).invoke("portal.open", { port: 3000 }); + expect(response).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "UNAVAILABLE", message: "portal bind failed" }), + ); + }); + + it("delivers portal changes only to read-capable operators", () => { + const events = new Map(); + const client = (id: string, role: "node" | "operator", scopes: string[]): GatewayWsClient => { + events.set(id, []); + return { + connId: id, + usesSharedGatewayAuth: false, + connect: { role, scopes } as GatewayWsClient["connect"], + socket: { + bufferedAmount: 0, + close: vi.fn(), + send: (value: string) => + events.get(id)?.push((JSON.parse(value) as { event: string }).event), + } as never, + }; + }; + const clients = new Set([ + client("pairing", "operator", ["operator.pairing"]), + client("node", "node", ["operator.read"]), + client("read", "operator", ["operator.read"]), + client("write", "operator", ["operator.write"]), + ]); + createGatewayBroadcaster({ clients }).broadcast("portal.changed", { portals: [portal] }); + + expect(events.get("pairing")).toEqual([]); + expect(events.get("node")).toEqual([]); + expect(events.get("read")).toEqual(["portal.changed"]); + expect(events.get("write")).toEqual(["portal.changed"]); + }); +}); diff --git a/src/gateway/server-methods/portals.ts b/src/gateway/server-methods/portals.ts new file mode 100644 index 000000000000..047de2ac8510 --- /dev/null +++ b/src/gateway/server-methods/portals.ts @@ -0,0 +1,121 @@ +import { + ErrorCodes, + errorShape, + formatValidationErrors, + type PortalCloseParams, + type PortalOpenParams, + type PortalSummary, + validatePortalCloseParams, + validatePortalListParams, + validatePortalOpenParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import { ADMIN_SCOPE, WRITE_SCOPE } from "../operator-scopes.js"; +import type { GatewayRequestHandlers, RespondFn } from "./types.js"; + +function invalidParams(method: string, errors: unknown, respond: RespondFn): void { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid ${method} params: ${formatValidationErrors(errors as never)}`, + ), + ); +} + +function requirePortalService( + context: Parameters[0]["context"], + respond: RespondFn, +) { + const service = context.portalService; + if (!service) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "portals unavailable")); + } + return service; +} + +function redactPortalSummary(summary: PortalSummary): PortalSummary { + const { tokenQuery: _tokenQuery, url: _url, ...redacted } = summary; + return redacted; +} + +export const portalHandlers: GatewayRequestHandlers = { + "portal.list": ({ params, respond, context, client }) => { + if (!validatePortalListParams(params)) { + invalidParams("portal.list", validatePortalListParams.errors, respond); + return; + } + const service = requirePortalService(context, respond); + if (!service) { + return; + } + const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; + const portals = service.list(); + respond( + true, + { + portals: + scopes.includes(WRITE_SCOPE) || scopes.includes(ADMIN_SCOPE) + ? portals + : portals.map(redactPortalSummary), + }, + undefined, + ); + }, + "portal.open": async ({ params, respond, context }) => { + if (!validatePortalOpenParams(params)) { + invalidParams("portal.open", validatePortalOpenParams.errors, respond); + return; + } + const service = requirePortalService(context, respond); + if (!service) { + return; + } + try { + const request = params as PortalOpenParams; + const portal = await service.open({ + targetPort: request.port, + ...(request.title !== undefined ? { title: request.title } : {}), + ...(request.description !== undefined ? { description: request.description } : {}), + ...(request.path !== undefined ? { path: request.path } : {}), + }); + context.broadcast( + "portal.changed", + { portals: service.list().map(redactPortalSummary) }, + { dropIfSlow: true }, + ); + respond(true, portal, undefined); + } catch (error) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, error instanceof Error ? error.message : String(error)), + ); + } + }, + "portal.close": async ({ params, respond, context }) => { + if (!validatePortalCloseParams(params)) { + invalidParams("portal.close", validatePortalCloseParams.errors, respond); + return; + } + const service = requirePortalService(context, respond); + if (!service) { + return; + } + try { + await service.close((params as PortalCloseParams).id); + context.broadcast( + "portal.changed", + { portals: service.list().map(redactPortalSummary) }, + { dropIfSlow: true }, + ); + respond(true, { closed: true }, undefined); + } catch (error) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, error instanceof Error ? error.message : String(error)), + ); + } + }, +}; diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 68271f0a3e7c..7640b19c3b76 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -34,6 +34,7 @@ import type { HealthSummary } from "../health/types.js"; import type { GatewayMethodRegistryView } from "../methods/descriptor.js"; import type { NodeRegistry } from "../node-registry.js"; import type { PluginNodeCapabilitySurface } from "../plugin-node-capability.js"; +import type { GatewayPortalService } from "../portals/portal-service.js"; import type { GatewayBroadcastFn, GatewayBroadcastToConnIdsFn } from "../server-broadcast-types.js"; import type { ChannelRuntimeSnapshot, @@ -280,6 +281,7 @@ type GatewayKernelContext = { /** Socket-bound services and connection state supplied by the Gateway transports. */ type GatewayTransportContext = { + portalService?: GatewayPortalService; getMcpAppSandboxPort?: () => number | undefined; ensureSandboxHostPort?: () => Promise; broadcast: GatewayBroadcastFn; diff --git a/src/gateway/server-methods/update-hold.test.ts b/src/gateway/server-methods/update-hold.test.ts index 2876ee953cb3..f31889522d0e 100644 --- a/src/gateway/server-methods/update-hold.test.ts +++ b/src/gateway/server-methods/update-hold.test.ts @@ -1,6 +1,11 @@ // Update hold tests cover campaign deferral and its validated schedule response. import { expectDefined } from "@openclaw/normalization-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + PortalCloseResultSchema, + PortalListResultSchema, + PortalSummarySchema, +} from "../../../packages/gateway-protocol/src/schema/portals.js"; type UpdateScheduleState = import("../../../packages/gateway-protocol/src/index.js").UpdateScheduleState; @@ -27,6 +32,9 @@ vi.mock("../../infra/update-startup.js", () => ({ })); vi.mock("../../../packages/gateway-protocol/src/index.js", () => ({ + PortalCloseResultSchema, + PortalListResultSchema, + PortalSummarySchema, validateUpdateHoldParams: () => true, validateUpdateHoldResult: validateUpdateHoldResultMock, validateUpdateRunParams: () => true, diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index 39642374f3f2..95942dbd24ec 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -38,6 +38,7 @@ type GatewayRequestContextParams = { sessionObserver: SessionObserverService; getMcpAppSandboxPort?: GatewayRequestContext["getMcpAppSandboxPort"]; ensureSandboxHostPort?: GatewayRequestContext["ensureSandboxHostPort"]; + getPortalService?: () => GatewayRequestContext["portalService"]; resolveTerminalLaunchPolicy: GatewayRequestContext["resolveTerminalLaunchPolicy"]; isTerminalEnabled: GatewayRequestContext["isTerminalEnabled"]; execApprovalManager: GatewayRequestContext["execApprovalManager"]; @@ -187,6 +188,9 @@ export function createGatewayRequestContext( notifyPluginMetadataChanged: params.notifyPluginMetadataChanged, getMcpAppSandboxPort: params.getMcpAppSandboxPort, ensureSandboxHostPort: params.ensureSandboxHostPort, + get portalService() { + return params.getPortalService?.(); + }, resolveTerminalLaunchPolicy: params.resolveTerminalLaunchPolicy, isTerminalEnabled: params.isTerminalEnabled, execApprovalManager: params.execApprovalManager, diff --git a/src/gateway/server-runtime-state-prepare.ts b/src/gateway/server-runtime-state-prepare.ts index cabbd53a1301..1d06e2a56e4f 100644 --- a/src/gateway/server-runtime-state-prepare.ts +++ b/src/gateway/server-runtime-state-prepare.ts @@ -556,6 +556,7 @@ export async function prepareGatewayKernelState(params: { getWorkerIngressEndpoint: transportBridge.getWorkerIngressEndpoint, getMcpAppSandboxPort: transportBridge.getMcpAppSandboxPort, ensureSandboxHostPort: transportBridge.ensureSandboxHostPort, + getPortalService: transportBridge.getPortalService, workerGatewayEndpoint, }; } diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 4244f7ebc4d6..f98b247835ac 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -25,6 +25,7 @@ import type { HooksConfigResolved } from "./hooks.js"; import type { AuthorizedGatewayHttpRequest } from "./http-auth-utils.js"; import { createSandboxHostHttpServer } from "./mcp-app-sandbox-http.js"; import { isLoopbackHost, resolveGatewayListenHosts } from "./net.js"; +import { createGatewayPortalService, type GatewayPortalService } from "./portals/portal-service.js"; import { MAX_PREAUTH_PAYLOAD_BYTES } from "./server-constants.js"; import { attachGatewayUpgradeHandler, @@ -130,6 +131,7 @@ export async function createGatewayHttpTransport(params: { startListening: () => Promise; wss: WebSocketServer; preauthConnectionBudget: PreauthConnectionBudget; + portalService: GatewayPortalService; getWorkerIngressEndpoint: () => { host: "127.0.0.1"; port: number } | undefined; getMcpAppSandboxPort: () => number | undefined; ensureSandboxHostPort: () => Promise; @@ -265,6 +267,11 @@ export async function createGatewayHttpTransport(params: { const httpServers: HttpServer[] = []; const gatewayHttpServers: HttpServer[] = []; const httpBindHosts: string[] = []; + const portalService = createGatewayPortalService({ + httpBindHosts, + httpServers, + ...(params.gatewayTls?.enabled ? { tlsOptions: params.gatewayTls.tlsOptions } : {}), + }); for (const _ of bindHosts) { const httpServer = createGatewayHttpServer({ clients: params.clients, @@ -494,6 +501,7 @@ export async function createGatewayHttpTransport(params: { startListening, wss, preauthConnectionBudget, + portalService, getWorkerIngressEndpoint: () => workerIngressPort === undefined ? undefined diff --git a/src/gateway/server-transport-bridge.ts b/src/gateway/server-transport-bridge.ts index b9cd6ae98389..081f2fae47df 100644 --- a/src/gateway/server-transport-bridge.ts +++ b/src/gateway/server-transport-bridge.ts @@ -11,6 +11,7 @@ export function createGatewayTransportBridge() { current = transport; }, current: () => current, + getPortalService: () => current?.portalService, getWorkerIngressEndpoint: () => current?.getWorkerIngressEndpoint(), getMcpAppSandboxPort: () => current?.getMcpAppSandboxPort(), ensureSandboxHostPort: async () => { diff --git a/src/gateway/tool-resolution.exclude.test.ts b/src/gateway/tool-resolution.exclude.test.ts index 2d412b4f7026..b5727439798b 100644 --- a/src/gateway/tool-resolution.exclude.test.ts +++ b/src/gateway/tool-resolution.exclude.test.ts @@ -330,6 +330,7 @@ describe("resolveGatewayScopedTools excludeToolNames", () => { "sessions", "screen", "terminal", + "portal", "conversations_list", "conversations_send", "conversations_turn", @@ -344,6 +345,7 @@ describe("resolveGatewayScopedTools excludeToolNames", () => { "sessions", "screen", "terminal", + "portal", "conversations_list", "conversations_send", "conversations_turn", 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", diff --git a/ui/src/app-navigation-groups.test.ts b/ui/src/app-navigation-groups.test.ts index 93d2694aec0a..4c782a7d2099 100644 --- a/ui/src/app-navigation-groups.test.ts +++ b/ui/src/app-navigation-groups.test.ts @@ -97,6 +97,14 @@ describe("sidebar entries", () => { expect(isSettingsNavigationRoute("apps")).toBe(false); }); + it("keeps Portals as a first-class customizable workspace route", () => { + expect(SIDEBAR_NAV_ROUTES).toContain("portals"); + expect(DEFAULT_SIDEBAR_ENTRIES).not.toContain("route:portals"); + expect(sidebarMoreRoutes(DEFAULT_SIDEBAR_ENTRIES)).toContain("portals"); + expect(settingsRoutes).not.toContain("portals"); + expect(isSettingsNavigationRoute("portals")).toBe(false); + }); + it("keeps the plugin manager in customizable workspace routes", () => { expect(normalizeSidebarEntries(["route:plugins", "route:usage", "route:plugins"])).toEqual([ "route:plugins", diff --git a/ui/src/app-navigation.test.ts b/ui/src/app-navigation.test.ts index a01cd55dcaa8..be407b513805 100644 --- a/ui/src/app-navigation.test.ts +++ b/ui/src/app-navigation.test.ts @@ -94,6 +94,7 @@ describe("navigationIconForRoute", () => { custodian: "lobster", activity: "activity", apps: "layoutGrid", + portals: "monitor", approvals: "badgeCheck", workboard: "kanban", dashboards: "layoutDashboard", @@ -216,6 +217,7 @@ describe("titleForRoute", () => { custodian: "OpenClaw", activity: "Activity", apps: "Apps", + portals: "Portals", approvals: "Approvals", workboard: "Workboard", dashboards: "Dashboards", @@ -266,6 +268,7 @@ describe("subtitleForRoute", () => { custodian: "System setup and care.", activity: "Browser-local tool activity summaries.", apps: "Companion apps for phone, watch, desktop, and browser.", + portals: "Live previews from agent-run applications.", approvals: "Recent exec, plugin, and system-agent approvals.", workboard: "Agent work queue and session handoff.", dashboards: "Sessions that open on their dashboard face.", @@ -311,6 +314,7 @@ describe("pathForRoute", () => { it("returns correct path without base", () => { expect(pathForRoute("chat")).toBe("/chat"); expect(pathForRoute("apps")).toBe("/apps"); + expect(pathForRoute("portals")).toBe("/portals"); expect(pathForRoute("dashboards")).toBe("/dashboards"); expect(pathForRoute("custodian")).toBe("/custodian"); expect(pathForRoute("connection")).toBe("/settings/connection"); @@ -349,6 +353,7 @@ describe("routeIdFromPath", () => { expect(routeIdFromPath("/connection")).toBeNull(); expect(routeIdFromPath("/activity")).toBe("activity"); expect(routeIdFromPath("/apps")).toBe("apps"); + expect(routeIdFromPath("/portals")).toBe("portals"); expect(routeIdFromPath("/dashboards")).toBe("dashboards"); expect(routeIdFromPath("/sessions")).toBe("sessions"); expect(routeIdFromPath("/debug")).toBe("debug"); @@ -515,6 +520,18 @@ describe("plugin tabs route", () => { describe("SIDEBAR_NAV_ROUTES", () => { it("all routes are unique", () => { + expect(SIDEBAR_NAV_ROUTES).toEqual([ + "workboard", + "dashboards", + "usage", + "cron", + "tasks", + "sessions", + "activity", + "plugins", + "apps", + "portals", + ]); expect(new Set(SIDEBAR_NAV_ROUTES).size).toBe(SIDEBAR_NAV_ROUTES.length); }); diff --git a/ui/src/app-navigation.ts b/ui/src/app-navigation.ts index 421c4dcc499b..3d5f055295d5 100644 --- a/ui/src/app-navigation.ts +++ b/ui/src/app-navigation.ts @@ -26,6 +26,7 @@ export const SIDEBAR_NAV_ROUTES = [ "activity", "plugins", "apps", + "portals", ] as const satisfies readonly NavigationRouteId[]; // Routes presented as tabs of the Plugins hub. The sidebar highlights the @@ -222,6 +223,7 @@ const NAVIGATION_ICONS: NavigationItem = { agents: "bot", activity: "activity", apps: "layoutGrid", + portals: "monitor", approvals: "badgeCheck", workboard: "kanban", worktrees: "folder", @@ -330,6 +332,7 @@ const NAVIGATION_COPY: Record { expect(routeIdFromPath("/settings/secrets")).toBe("secrets"); }); + it("registers the Portals workspace path", () => { + expect(pathForRoute("portals")).toBe("/portals"); + expect(routeIdFromPath("/portals")).toBe("portals"); + }); + it.each(DYNAMIC_STARTUP_CASES)( "loads the $label once while publishing its real location", async ({ routeId, location: initialLocation }) => { diff --git a/ui/src/app-route-paths.ts b/ui/src/app-route-paths.ts index f8517d25736c..8b5f9acb2956 100644 --- a/ui/src/app-route-paths.ts +++ b/ui/src/app-route-paths.ts @@ -27,6 +27,7 @@ const APP_ROUTE_DEFINITIONS = { "new-session": { path: "/new" }, activity: { path: "/activity" }, apps: { path: "/apps" }, + portals: { path: "/portals" }, agents: { path: "/settings/agents", aliases: ["/agents"] }, channels: { path: "/settings/channels", aliases: ["/channels"] }, connection: { path: "/settings/connection" }, diff --git a/ui/src/app-routes.test.ts b/ui/src/app-routes.test.ts index 70f7b1a7fa7a..d26bac2770a6 100644 --- a/ui/src/app-routes.test.ts +++ b/ui/src/app-routes.test.ts @@ -12,6 +12,7 @@ describe("application router registration", () => { it("registers every route id exactly once", () => { const routeIds = router.routes.map((route) => route.id); + expect(routeIds).toContain("portals"); expect([...routeIds].toSorted()).toEqual([...APP_ROUTE_IDS].toSorted()); }); diff --git a/ui/src/app-routes.ts b/ui/src/app-routes.ts index f442e09d16c9..c36848326f35 100644 --- a/ui/src/app-routes.ts +++ b/ui/src/app-routes.ts @@ -47,6 +47,7 @@ import { page as modelSetupPage } from "./pages/model-setup/route.ts"; import { page as newSessionPage } from "./pages/new-session/route.ts"; import { page as pluginPage } from "./pages/plugin/route.ts"; import { page as pluginsPage } from "./pages/plugins/route.ts"; +import { page as portalsPage } from "./pages/portals/route.ts"; import { page as profilePage } from "./pages/profile/route.ts"; import { page as secretsPage } from "./pages/secrets/route.ts"; import { page as sessionsPage } from "./pages/sessions/route.ts"; @@ -80,6 +81,7 @@ const APP_ROUTE_TREE = [ activityPage, dashboardsPage, appsPage, + portalsPage, agentsPage, approvalsPage, channelsPage, diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 169d24adc20b..7f5653728a07 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -2045,6 +2045,7 @@ export const en: TranslationMap = { agents: "Agents", activity: "Activity", apps: "Apps", + portals: "Portals", approvals: "Approvals", workboard: "Workboard", worktrees: "Worktrees", @@ -2086,6 +2087,7 @@ export const en: TranslationMap = { agents: "Workspaces, tools, identities.", activity: "Browser-local tool activity summaries.", apps: "Companion apps for phone, watch, desktop, and browser.", + portals: "Live previews from agent-run applications.", approvals: "Recent exec, plugin, and system-agent approvals.", workboard: "Agent work queue and session handoff.", worktrees: "Isolated agent task checkouts and recovery snapshots.", @@ -2126,6 +2128,27 @@ export const en: TranslationMap = { logs: "Live gateway logs.", plugin: "Plugin-provided panel.", }, + portalsPage: { + listLabel: "Active portals", + portLabel: "Port {port}", + openNewTab: "Open in new tab", + closePortal: "Close {title}", + previewTitle: "{title} portal preview", + loading: "Loading portals…", + emptyHint: "Ask the agent to start a portal:", + promptShow: "Show me in a portal.", + promptStart: "Start the application in a portal.", + promptMakeAvailable: "Make the server available in a portal.", + unsupported: "This gateway does not support portals.", + loadFailed: "Could not load portals: {error}", + closeFailed: "Could not close the portal: {error}", + unreachableTitle: "Portal not reachable from this browser", + unreachableBody: + "The Gateway is likely being accessed through a proxy or tunnel that exposes only its main port. Open this URL from a browser on the Gateway host.", + writeAccessRequiredTitle: "Write access required", + writeAccessRequiredBody: "This portal requires an operator with write access.", + retry: "Retry", + }, modelSetup: { heading: "Connect a verified AI model", intro: diff --git a/ui/src/pages/portals/portal-reachability.test.ts b/ui/src/pages/portals/portal-reachability.test.ts new file mode 100644 index 000000000000..27d33de59c55 --- /dev/null +++ b/ui/src/pages/portals/portal-reachability.test.ts @@ -0,0 +1,52 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { probePortalReachable } from "./portal-reachability.ts"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("probePortalReachable", () => { + it("accepts any settled no-cors response", async () => { + const fetchMock = vi.fn().mockResolvedValue({ type: "opaque" }); + vi.stubGlobal("fetch", fetchMock); + + await expect(probePortalReachable("https://gateway.example.test:43123/app")).resolves.toBe( + true, + ); + expect(fetchMock).toHaveBeenCalledWith( + "https://gateway.example.test:43123/app", + expect.objectContaining({ mode: "no-cors", signal: expect.any(AbortSignal) }), + ); + }); + + it("returns false when the reachability deadline aborts the request", async () => { + const controller = new AbortController(); + const timeoutMock = vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); + vi.stubGlobal( + "fetch", + vi.fn((_url: string, init: RequestInit) => { + return new Promise((_resolve, reject) => { + init.signal?.addEventListener( + "abort", + () => + reject( + init.signal?.reason instanceof Error + ? init.signal.reason + : new Error("Request aborted"), + ), + { once: true }, + ); + }); + }), + ); + + const result = probePortalReachable("https://gateway.example.test:43123/app"); + controller.abort(new DOMException("Timed out", "TimeoutError")); + + await expect(result).resolves.toBe(false); + expect(timeoutMock).toHaveBeenCalledWith(4_000); + }); +}); diff --git a/ui/src/pages/portals/portal-reachability.ts b/ui/src/pages/portals/portal-reachability.ts new file mode 100644 index 000000000000..2363ea1b48e8 --- /dev/null +++ b/ui/src/pages/portals/portal-reachability.ts @@ -0,0 +1,13 @@ +const PORTAL_REACHABILITY_TIMEOUT_MS = 4_000; + +export async function probePortalReachable(url: string): Promise { + try { + await fetch(url, { + mode: "no-cors", + signal: AbortSignal.timeout(PORTAL_REACHABILITY_TIMEOUT_MS), + }); + return true; + } catch { + return false; + } +} diff --git a/ui/src/pages/portals/portal-url.ts b/ui/src/pages/portals/portal-url.ts new file mode 100644 index 000000000000..06d4088545a7 --- /dev/null +++ b/ui/src/pages/portals/portal-url.ts @@ -0,0 +1,14 @@ +import type { PortalSummary } from "@openclaw/gateway-protocol"; +import { resolveGatewayHttpOrigin } from "../../components/sandbox-host.ts"; + +export function resolvePortalUrl( + portal: Pick & { tokenQuery: string }, + gatewayUrl: string, + hostOrigin: string, +): string { + const url = new URL(resolveGatewayHttpOrigin(gatewayUrl, hostOrigin)); + url.port = String(portal.listenPort); + url.pathname = portal.path ?? "/"; + url.search = portal.tokenQuery; + return url.href; +} diff --git a/ui/src/pages/portals/portals-page.test.ts b/ui/src/pages/portals/portals-page.test.ts new file mode 100644 index 000000000000..ed774aaf041e --- /dev/null +++ b/ui/src/pages/portals/portals-page.test.ts @@ -0,0 +1,199 @@ +/* @vitest-environment jsdom */ + +import type { PortalListResult, PortalSummary } from "@openclaw/gateway-protocol"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient, GatewayEventFrame } from "../../api/gateway.ts"; +import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts"; +import { resolvePortalUrl } from "./portal-url.ts"; + +const probePortalReachable = vi.hoisted(() => vi.fn<() => Promise>()); + +vi.mock("./portal-reachability.ts", () => ({ probePortalReachable })); + +import "./portals-page.ts"; + +type PortalsPageTestElement = HTMLElement & { + context: ApplicationContext; + updateComplete: Promise; +}; + +const portal = { + id: "p3000", + title: "Seeded app", + port: 3000, + listenPort: 43_123, + tokenQuery: "openclaw_portal=secret-token", + url: "http://127.0.0.1:43123/app?openclaw_portal=secret-token", + publicUrl: "http://127.0.0.1:43123/app", + path: "/app", + description: "Use the seeded test account.", + createdAtMs: 1_000, +} satisfies PortalSummary; + +function createContext( + methods: string[], + request: (method: string, params: Record) => Promise, +) { + const requestMock = vi.fn(request); + const client = { request: requestMock } as unknown as GatewayBrowserClient; + const snapshot: ApplicationGatewaySnapshot = { + client, + phase: "connected", + offlineStable: false, + canvasPluginSurfaceUrl: null, + hello: { features: { methods } } as ApplicationGatewaySnapshot["hello"], + assistantAgentId: null, + sessionKey: "main", + lastError: null, + lastErrorCode: null, + }; + const eventListeners = new Set<(event: GatewayEventFrame) => void>(); + const gateway = { + snapshot, + connection: { + gatewayUrl: "wss://gateway.example.test:18789/control", + token: "", + bootstrapToken: "", + password: "", + }, + subscribe: () => () => undefined, + subscribeEvents(listener: (event: GatewayEventFrame) => void) { + eventListeners.add(listener); + return () => eventListeners.delete(listener); + }, + } as unknown as ApplicationContext["gateway"]; + return { + context: { gateway } as unknown as ApplicationContext, + emitPortals(portals: PortalSummary[]) { + for (const listener of eventListeners) { + listener({ type: "event", event: "portal.changed", payload: { portals } }); + } + }, + request: requestMock, + }; +} + +async function mountPage(context: ApplicationContext) { + const page = document.createElement("openclaw-portals-page") as PortalsPageTestElement; + page.context = context; + document.body.append(page); + await page.updateComplete; + return page; +} + +afterEach(() => { + document.body.replaceChildren(); + vi.restoreAllMocks(); +}); + +beforeEach(() => { + probePortalReachable.mockReset().mockResolvedValue(true); +}); + +describe("PortalsPage", () => { + it("renders the portal list and refetches it after replacement events", async () => { + const source = createContext(["portal.list", "portal.close"], async (method) => { + if (method === "portal.list") { + return { portals: [portal] } satisfies PortalListResult; + } + return { closed: true }; + }); + const page = await mountPage(source.context); + + await vi.waitFor(() => { + expect(page.querySelector(".portals-rail__title")?.textContent).toBe("Seeded app"); + }); + expect(page.querySelector(".portals-rail__item")?.textContent).toContain("Port 3000"); + expect(page.querySelector(".portals-rail__item")?.textContent).toContain( + "Use the seeded test account.", + ); + const frame = page.querySelector("iframe"); + expect(frame?.getAttribute("src")).toBe( + "https://gateway.example.test:43123/app?openclaw_portal=secret-token", + ); + expect(frame?.getAttribute("referrerpolicy")).toBe("no-referrer"); + expect(frame?.getAttribute("sandbox")).toBe( + "allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts", + ); + expect(probePortalReachable).toHaveBeenCalledWith( + "https://gateway.example.test:43123/app?openclaw_portal=secret-token", + ); + + source.emitPortals([]); + + await vi.waitFor(() => { + expect(source.request).toHaveBeenCalledTimes(2); + }); + expect(source.request).toHaveBeenLastCalledWith("portal.list", {}); + expect(page.querySelector(".portals-rail__title")?.textContent).toBe("Seeded app"); + }); + + it("requires write access instead of opening a portal without credentials", async () => { + const { tokenQuery: _tokenQuery, url: _url, ...redactedPortal } = portal; + const source = createContext(["portal.list"], async () => ({ + portals: [redactedPortal as PortalSummary], + })); + const page = await mountPage(source.context); + + await vi.waitFor(() => { + expect(page.textContent).toContain("This portal requires an operator with write access."); + }); + expect(page.querySelector("iframe")).toBeNull(); + expect(page.querySelector(".portals-preview__url")).toBeNull(); + expect(probePortalReachable).not.toHaveBeenCalled(); + }); + + it("shows an unreachable notice without mounting the iframe and retries", async () => { + probePortalReachable.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + const source = createContext(["portal.list", "portal.close"], async (method) => { + if (method === "portal.list") { + return { portals: [portal] } satisfies PortalListResult; + } + return { closed: true }; + }); + const page = await mountPage(source.context); + + await vi.waitFor(() => { + expect(page.textContent).toContain("Portal not reachable from this browser"); + }); + expect(page.querySelector("iframe")).toBeNull(); + + page.querySelector(".portals-preview__close")?.click(); + await vi.waitFor(() => { + expect(source.request).toHaveBeenCalledWith("portal.close", { id: portal.id }); + }); + + const retry = [...page.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Retry", + ); + expect(retry).toBeDefined(); + retry?.click(); + + await vi.waitFor(() => expect(page.querySelector("iframe")).not.toBeNull()); + expect(probePortalReachable).toHaveBeenCalledTimes(2); + }); + + it("shows the empty prompts and an unsupported note without calling the method", async () => { + const source = createContext([], async () => ({ portals: [] })); + const page = await mountPage(source.context); + + expect(page.textContent).toContain("Ask the agent to start a portal:"); + expect(page.textContent).toContain("Show me in a portal."); + expect(page.textContent).toContain("Start the application in a portal."); + expect(page.textContent).toContain("Make the server available in a portal."); + expect(page.textContent).toContain("This gateway does not support portals."); + expect(source.request).not.toHaveBeenCalled(); + }); +}); + +describe("resolvePortalUrl", () => { + it("uses the resolved gateway host and scheme with the portal listener port", () => { + expect( + resolvePortalUrl( + portal, + "wss://gateway.example.test:18789/control", + "http://control-ui.example.test", + ), + ).toBe("https://gateway.example.test:43123/app?openclaw_portal=secret-token"); + }); +}); diff --git a/ui/src/pages/portals/portals-page.ts b/ui/src/pages/portals/portals-page.ts new file mode 100644 index 000000000000..1968a85b93d0 --- /dev/null +++ b/ui/src/pages/portals/portals-page.ts @@ -0,0 +1,381 @@ +import { consume } from "@lit/context"; +import type { + PortalCloseResult, + PortalListResult, + PortalSummary, +} from "@openclaw/gateway-protocol"; +import { html, nothing } from "lit"; +import { state } from "lit/decorators.js"; +import { keyed } from "lit/directives/keyed.js"; +import { ref } from "lit/directives/ref.js"; +import { titleForRoute } from "../../app-navigation.ts"; +import { applicationContext, type ApplicationContext } from "../../app/context.ts"; +import { icon } from "../../components/icons.ts"; +import { t } from "../../i18n/index.ts"; +import { formatUiError } from "../../lib/format-error.ts"; +import { canCallGatewayMethod, isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts"; +import { GatewayPageController } from "../../lit/gateway-page-controller.ts"; +import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; +import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; +import { probePortalReachable } from "./portal-reachability.ts"; +import { resolvePortalUrl } from "./portal-url.ts"; +import "./portals.css"; + +const PORTAL_FRAME_SANDBOX = + "allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts"; + +type PortalProbeState = { + key: string; + status: "probing" | "reachable" | "unreachable"; +}; + +class PortalsPage extends OpenClawLightDomElement { + @consume({ context: applicationContext, subscribe: true }) + private context!: ApplicationContext; + + @state() private portals: PortalSummary[] = []; + @state() private selectedPortalId: string | null = null; + @state() private loading = false; + @state() private loaded = false; + @state() private error: string | null = null; + @state() private closingPortalId: string | null = null; + @state() private portalProbeState: PortalProbeState | null = null; + + private requestGeneration = 0; + private portalSetRevision = 0; + private portalProbeGeneration = 0; + private readonly portalProbeCache = new Map(); + private readonly gateway = new GatewayPageController(this, { + getGateway: () => this.context?.gateway, + invalidateRequests: () => this.resetGatewayState(), + ensureInitialData: () => void this.loadPortals(), + }); + private readonly subscriptions = new SubscriptionsController(this).effect( + () => this.context?.gateway, + (gateway) => + gateway.subscribeEvents((event) => { + if ( + this.gateway.gateway !== gateway || + this.context.gateway !== gateway || + !this.gateway.connected || + event.event !== "portal.changed" + ) { + return; + } + void this.loadPortals(); + }), + ); + + override disconnectedCallback() { + this.portalProbeGeneration += 1; + this.subscriptions.clear(); + super.disconnectedCallback(); + } + + private get portalListSupported(): boolean { + return isGatewayMethodAdvertised(this.gateway.snapshot ?? {}, "portal.list") !== false; + } + + private get canClosePortal(): boolean { + return canCallGatewayMethod(this.gateway.snapshot, "portal.close", "operator.write"); + } + + private resetGatewayState() { + this.requestGeneration += 1; + this.portalSetRevision += 1; + this.portals = []; + this.selectedPortalId = null; + this.loading = false; + this.loaded = false; + this.error = null; + this.closingPortalId = null; + this.portalProbeGeneration += 1; + this.portalProbeCache.clear(); + this.portalProbeState = null; + } + + private applyPortalSet(portals: readonly PortalSummary[]) { + this.portalSetRevision += 1; + this.portals = [...portals]; + const previousPortalId = this.selectedPortalId; + const selectedPortalId = portals.some((portal) => portal.id === previousPortalId) + ? this.selectedPortalId + : (portals[0]?.id ?? null); + this.selectedPortalId = selectedPortalId; + this.loaded = true; + this.error = null; + const selectedPortal = portals.find((portal) => portal.id === selectedPortalId); + if (selectedPortal) { + this.ensurePortalProbe(selectedPortal, selectedPortalId !== previousPortalId); + } else { + this.portalProbeGeneration += 1; + this.portalProbeState = null; + } + } + + private portalUrl(portal: PortalSummary, tokenQuery: string): string { + return resolvePortalUrl( + { ...portal, tokenQuery }, + this.context.gateway.connection.gatewayUrl, + window.location.origin, + ); + } + + private ensurePortalProbe(portal: PortalSummary, force = false) { + const tokenQuery = portal.tokenQuery; + if (!tokenQuery) { + this.portalProbeGeneration += 1; + this.portalProbeState = null; + return; + } + const url = this.portalUrl(portal, tokenQuery); + const key = `${portal.id}\u0000${url}`; + if (!force && this.portalProbeState?.key === key) { + return; + } + const cached = force ? undefined : this.portalProbeCache.get(key); + if (cached !== undefined) { + this.portalProbeState = { key, status: cached ? "reachable" : "unreachable" }; + return; + } + + const generation = ++this.portalProbeGeneration; + this.portalProbeState = { key, status: "probing" }; + void probePortalReachable(url).then((reachable) => { + this.portalProbeCache.set(key, reachable); + if (generation === this.portalProbeGeneration && this.portalProbeState?.key === key) { + this.portalProbeState = { key, status: reachable ? "reachable" : "unreachable" }; + } + }); + } + + private selectPortal(portal: PortalSummary) { + if (portal.id === this.selectedPortalId) { + return; + } + this.selectedPortalId = portal.id; + this.ensurePortalProbe(portal, true); + } + + private async loadPortals() { + if (!this.gateway.connected || !this.portalListSupported || this.loading) { + return; + } + const client = this.gateway.client; + const scope = this.gateway.capture(); + if (!client || !scope) { + return; + } + const generation = ++this.requestGeneration; + const portalSetRevision = this.portalSetRevision; + this.loading = true; + this.error = null; + try { + const result = await client.request("portal.list", {}); + if ( + generation === this.requestGeneration && + portalSetRevision === this.portalSetRevision && + this.gateway.isCurrent(scope) + ) { + this.applyPortalSet(result.portals); + } + } catch (error) { + if ( + generation === this.requestGeneration && + this.gateway.isCurrent(scope) && + this.portalListSupported + ) { + this.error = t("portalsPage.loadFailed", { error: formatUiError(error) }); + this.loaded = true; + } + } finally { + if (generation === this.requestGeneration && this.gateway.isCurrent(scope)) { + this.loading = false; + } + } + } + + private async closePortal(portal: PortalSummary) { + if (!this.canClosePortal || this.closingPortalId) { + return; + } + const client = this.gateway.client; + const scope = this.gateway.capture(); + if (!client || !scope) { + return; + } + this.closingPortalId = portal.id; + this.error = null; + try { + await client.request("portal.close", { id: portal.id }); + if (this.gateway.isCurrent(scope)) { + void this.loadPortals(); + } + } catch (error) { + if (this.gateway.isCurrent(scope)) { + this.error = t("portalsPage.closeFailed", { error: formatUiError(error) }); + } + } finally { + if (this.gateway.isCurrent(scope) && this.closingPortalId === portal.id) { + this.closingPortalId = null; + } + } + } + + private renderEmptyState() { + const unsupported = !this.portalListSupported; + return html` +
+ ${this.loading && !this.loaded + ? html`
${t("portalsPage.loading")}
` + : html` +
${t("portalsPage.emptyHint")}
+
+ ${t("portalsPage.promptShow")} + ${t("portalsPage.promptStart")} + ${t("portalsPage.promptMakeAvailable")} +
+ `} + ${unsupported + ? html`
${t("portalsPage.unsupported")}
` + : nothing} + ${this.error ? html`
${this.error}
` : nothing} +
+ `; + } + + private renderPortal(portal: PortalSummary) { + if (!portal.tokenQuery) { + return html` +
+
+
+ ${t("portalsPage.writeAccessRequiredTitle")} +
+

${t("portalsPage.writeAccessRequiredBody")}

+
+
+ `; + } + const portalUrl = this.portalUrl(portal, portal.tokenQuery); + const frameKey = `${portal.id}\u0000${portalUrl}`; + const probeStatus = + this.portalProbeState?.key === frameKey ? this.portalProbeState.status : "probing"; + return html` +
+
+ + ${portalUrl} + ${icon("externalLink")} + ${t("portalsPage.openNewTab")} + + +
+ ${this.error + ? html`
${this.error}
` + : nothing} + ${probeStatus === "probing" + ? html` +
+
${t("portalsPage.loading")}
+
+ ` + : probeStatus === "unreachable" + ? html` +
+
+ ${t("portalsPage.unreachableTitle")} +
+

${t("portalsPage.unreachableBody")}

+ ${portalUrl} + +
+ ` + : keyed( + frameKey, + html``, + )} +
+ `; + } + + override render() { + const selectedPortal = + this.portals.find((portal) => portal.id === this.selectedPortalId) ?? this.portals[0]; + return html` +
+
+
${titleForRoute("portals")}
+
+
+ ${selectedPortal + ? html` +
+ + ${this.renderPortal(selectedPortal)} +
+ ` + : this.renderEmptyState()} + `; + } +} + +if (!customElements.get("openclaw-portals-page")) { + customElements.define("openclaw-portals-page", PortalsPage); +} diff --git a/ui/src/pages/portals/portals.css b/ui/src/pages/portals/portals.css new file mode 100644 index 000000000000..c2835ff6722f --- /dev/null +++ b/ui/src/pages/portals/portals.css @@ -0,0 +1,215 @@ +.portals-layout { + display: grid; + grid-template-columns: minmax(210px, 260px) minmax(0, 1fr); + gap: var(--space-4); + min-height: min(720px, calc(100vh - 190px)); +} + +.portals-rail { + min-width: 0; + padding: 4px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 10px; + background: color-mix(in srgb, var(--panel) 76%, transparent); +} + +.portals-rail__item { + width: 100%; + display: grid; + gap: 4px; + padding: 12px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--text); + font: inherit; + text-align: left; + cursor: var(--cursor-action); +} + +.portals-rail__item:hover, +.portals-rail__item:focus-visible { + background: var(--bg-hover); +} + +.portals-rail__item.active { + background: var(--accent-subtle); +} + +.portals-rail__title { + overflow: hidden; + color: var(--text-strong); + font-size: 0.86rem; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.portals-rail__port, +.portals-rail__description { + color: var(--muted); + font-size: var(--control-ui-text-xs); + line-height: 1.35; +} + +.portals-rail__description { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.portals-preview { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--panel); +} + +.portals-preview__header { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px 8px 14px; + border-bottom: 1px solid var(--border); +} + +.portals-preview__url { + min-width: 0; + display: inline-flex; + flex: 1; + align-items: center; + gap: 7px; + color: var(--muted); + font-family: var(--mono); + font-size: var(--control-ui-text-xs); + text-decoration: none; +} + +.portals-preview__url:hover, +.portals-preview__url:focus-visible { + color: var(--text); +} + +.portals-preview__url span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.portals-preview__url svg { + width: 14px; + height: 14px; + flex: 0 0 auto; +} + +.portals-preview__close { + opacity: 0.42; + transition: opacity 120ms ease; +} + +.portals-preview:hover .portals-preview__close, +.portals-preview__close:focus-visible { + opacity: 1; +} + +.portals-preview__error { + margin: 10px; +} + +.portals-preview__state { + flex: 1; + min-height: 480px; +} + +.portals-preview__notice { + min-height: 480px; + display: grid; + place-content: center; + justify-items: center; + gap: var(--space-3); + padding: var(--space-6); + color: var(--muted); + text-align: center; +} + +.portals-preview__notice-title { + color: var(--text); + font-size: 0.95rem; + font-weight: 650; +} + +.portals-preview__notice p { + max-width: 560px; + margin: 0; + line-height: 1.5; +} + +.portals-preview__notice-url { + max-width: min(680px, 100%); + overflow-wrap: anywhere; + color: var(--accent); + font-family: var(--mono); + font-size: var(--control-ui-text-xs); + user-select: all; +} + +.portals-preview__frame { + flex: 1; + width: 100%; + height: 100%; + min-height: 480px; + border: 0; + background: white; +} + +.portals-empty { + min-height: min(620px, calc(100vh - 190px)); + display: grid; + place-content: center; + justify-items: center; + gap: var(--space-3); + padding: var(--space-6) var(--space-4); + color: var(--muted); + text-align: center; +} + +.portals-empty__title { + color: var(--text); + font-size: 0.95rem; + font-weight: 650; +} + +.portals-empty__prompts { + display: grid; + gap: 5px; + font-size: var(--control-ui-text-sm); + line-height: 1.45; +} + +.portals-empty__note { + margin-top: var(--space-2); + color: var(--warn); + font-size: var(--control-ui-text-sm); +} + +@media (max-width: 760px) { + .portals-layout { + grid-template-columns: minmax(0, 1fr); + min-height: auto; + } + + .portals-rail { + max-height: 190px; + } + + .portals-preview { + min-height: 560px; + } +} diff --git a/ui/src/pages/portals/route.ts b/ui/src/pages/portals/route.ts new file mode 100644 index 000000000000..96a90c13ce62 --- /dev/null +++ b/ui/src/pages/portals/route.ts @@ -0,0 +1,12 @@ +import { definePage } from "@openclaw/uirouter"; +import { html } from "lit"; +import { routePageSpec } from "../../app-route-paths.ts"; + +export const page = definePage({ + ...routePageSpec("portals"), + component: () => + import("./portals-page.ts").then(() => ({ + header: true, + render: () => html``, + })), +});