feat: portals — expose agent-run dev servers to the operator (#122536)

* feat(protocol): add portal methods and event

Bump the reviewed protocol owner-module count from 55 to 56.

* feat(gateway): add portal service and reverse proxy

* feat(agents): add portal tool

* fix(gateway): refine portal URL and proxy auth

* refactor(gateway): keep portal helper types private

* fix(gateway): declare portal transport service

* test(gateway): satisfy portal proxy lint

* test(gateway): narrow websocket payload types

* refactor(protocol): compact portal schema exports

* fix(gateway): export portal protocol types

* feat(ui): add portals page

* docs(gateway): add portals guide

* fix(gateway): dial portal targets via localhost dual-stack

Vite and other Node >=17 dev servers bind ::1 only for localhost, so a
fixed 127.0.0.1 dial 502s on the default path. Use hostname localhost
with family autoselection and rewrite Host to match.

* fix(gateway): type portal dual-stack connection

* fix: satisfy portal integration gates

* fix(gateway): isolate portal cookie jars per target

Cookies are hostname-scoped, not port-scoped, so the per-port origin
split alone let Gateway plugin-auth cookies reach agent-run targets.
Forward only cookies carrying this portal's own name prefix (stripped),
rewrite target Set-Cookie names to the prefixed form incl. the WS 101
handshake, and drop Domain attributes.

* fix(ui): detect unreachable portals behind proxied gateways

Probe the portal origin from the browser (no-cors, 4s timeout) and show
a recovery notice with the gateway-host URL instead of a dead iframe
when only the gateway port is exposed (Serve/Funnel/reverse proxy).
Docs: cookie isolation + reachability; zh-CN glossary entry.

* test(ui): satisfy portal reachability lint

* test(gateway): provide control UI request hosts

* chore(protocol): regenerate after rebase

* fix(gateway): namespace portal auth cookies by listener

* fix(gateway): scope portal token URLs to write-capable clients

The portal bearer token rides in the summary url/tokenQuery; portal.list
is operator.read and portal.changed fans out to read subscribers, so a
read-only client could harvest an openable URL. Make those fields
optional, redact them from read-scope list responses, and drop them from
every portal.changed broadcast; write/admin clients still receive them
and the UI refetches the list on change.

* docs(web): list the portals route

* fix(gateway): type portal open credentials

* docs(gateway): clarify portals PORT/PUBLIC_URL are agent-set

Opening a portal creates only the proxy listener; the agent sets PORT
and PUBLIC_URL in its own exec command, matching the portal tool
contract. Removes the implication of an automatic env handoff.

* chore(protocol): regenerate portal models

* style(gateway): format portal method-order assertions

Rebase union-merge left the portal.list assertion wrapped; oxfmt fits it
on one line.

* chore(plugin-sdk): refresh API baseline after rebase

* chore(plugin-sdk): refresh API baseline after rebase

* chore(protocol): refresh portal event order after rebase

* chore(plugin-sdk): refresh API baseline after rebase

* fix(gateway): pin portal referrer policy to no-referrer

The portal URL carries its bearer token in the query, and upstream
response headers are copied verbatim, so a target answering with
Referrer-Policy: unsafe-url could leak that URL to every third-party
origin it references. Force no-referrer after the copy and drop any
inbound Referer that still carries the token before forwarding.
This commit is contained in:
Peter Steinberger
2026-08-13 00:46:11 -07:00
committed by GitHub
parent 4afccbdaa4
commit cc2fc55f9b
87 changed files with 3376 additions and 38 deletions
@@ -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"),
}
@@ -74,6 +74,17 @@
"cwd"
]
},
"portal": {
"emoji": "🌐",
"title": "Portal",
"detailKeys": [
"action",
"port",
"id",
"title",
"path"
]
},
"process": {
"emoji": "🧰",
"title": "Process",
@@ -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)
@@ -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"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"f639fea8b8ee53626452bdbce156724b09a3a29bb05a134eb8e8f8fb8062d4da","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
{"contentHash":"8407ad2fc13c783155999acdbef231efe60ba69538448a955ff5f10888e51d31","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"02db4abe2f1f4578d438f7afacd68d44114e46b48b9582711e7e10191353e6c5","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
{"contentHash":"f44495c27167838fbcdb0ba6afaafd770689e33bb57f725b45b8f9ac20af1565","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
@@ -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"}
@@ -1 +1 @@
{"contentHash":"bc8881a906f40f0a3ede29eb83efc1e4d3e59b62c00154b2480d85d69cbe4010","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
{"contentHash":"90a7e6988de562edab9ee28696207bba9fba6d83488dc62d9e0b893ca9603dc6","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
@@ -1 +1 @@
{"contentHash":"f4b35d03ac9df9788462f3e50b64819ff85aba0245b1a51208a288bd994edb8e","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
{"contentHash":"43cedd1b0efa245682de25aa7922045223c58a004bd6a424f1c98968f50396bf","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
@@ -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"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"edfa6b7a219aef521935ac17a20c5cd74e91bf4988eb3fad5ac75fc2e5015596","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
{"contentHash":"41d0227914b1eaf4f05dcbc6a92cbe1c824ae9415df8472a5edc0492f5b764ca","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"6490014377554ea6b62c657ab22c53fcec385b4ad24cdfccd95ebd9a79717e59","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
{"contentHash":"2e71c5a50c2efb3af7410ffb1cf7b6a6321b4f279c78ab5377fc984321251697","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
@@ -1 +1 @@
{"contentHash":"b691fcfb34a5f228938d06c50f9f8a26bb63ce06644e9df25f44876601e4cebb","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
{"contentHash":"067122d86f2c4c36fdebd428eb0183210a4cf3d5ac1ba5f082ae1120b57d14a3","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
@@ -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"}
@@ -1 +1 @@
{"contentHash":"9060d4011e1249ca8aa0b01f2e2825d71b29eff14dc84f7f702440dc01e73460","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
{"contentHash":"a3a31f78a73ea9159d1779d354b84cff1036741ee3980367c5effc4dd29b1b67","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"a36c13699a318b3fb1b6701ab3280c45e5a4f5ca982cef29778d10af8a5ae97a","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
{"contentHash":"64593f3e4ff693f2041d34fd5f3ee6e96795b9b2d49bbd60173d89da7d203494","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"bdf0b57a425cac872d156021006cce6813893bee30e77c5ef4c055343697dba0","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
{"contentHash":"9e2037874e2efb4faa53078d6c71089927097ccf08ecb4ba5ca9bd3f4b64414a","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
@@ -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"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"3662752cd7db434787d728355a4fb46e8f4be5b88dece9c29d96225bc02da6db","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
{"contentHash":"abf5f77043e2e1b8210541ed48d6ab5e22ec475bf05484d5f6103f82cfdd9f38","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
@@ -1 +1 @@
{"contentHash":"66ab292503af6befc63d5962f3312a8ebab4ab3bf3a56f47fe4f6a465d7c40b9","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
{"contentHash":"9c8a8d0ccdb9961abc35ca210b183a307df34e4ca2b1cf645d5db652cd7f8677","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
+4
View File
@@ -1746,5 +1746,9 @@
{
"source": "Connect a machine",
"target": "连接机器"
},
{
"source": "Portals",
"target": "门户"
}
]
+1
View File
@@ -1725,6 +1725,7 @@
"network",
"gateway/pairing",
"gateway/discovery",
"gateway/portals",
"gateway/bonjour"
]
}
+84
View File
@@ -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_<targetPort>_` 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.
+1
View File
@@ -137,6 +137,7 @@ no route-specific URL parameters.
| New session | `/new` | - | `?agent=<agentId>`, `?catalog=<catalogId>` |
| Activity | `/activity` | - | `?view=run&run=<run-id>`, `?view=run&execution=<execution-id>` |
| Apps | `/apps` | - | - |
| Portals | `/portals` | - | - |
| Agents | `/settings/agents` | `/agents` | `/settings/agents/<agentId>[/<panel>]` |
| Channels | `/settings/channels` | `/channels` | Shared settings parameters below |
| Connection | `/settings/connection` | - | Shared settings parameters below |
+1
View File
@@ -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,
@@ -50,6 +50,7 @@ export * from "./schema/terminal.js";
export * from "./schema/ui-command.js";
export * from "./schema/plugin-approvals.js";
export * from "./schema/plugins.js";
export * from "./schema/portals.js";
export * from "./schema/projects.js";
export * from "./schema/wizard.js";
export * from "./schema/worker-admission.js";
@@ -0,0 +1,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);
});
});
@@ -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<typeof PortalSummarySchema>;
export type PortalListParams = Static<typeof PortalListParamsSchema>;
export type PortalListResult = Static<typeof PortalListResultSchema>;
export type PortalOpenParams = Static<typeof PortalOpenParamsSchema>;
export type PortalOpenResult = Static<typeof PortalOpenResultSchema>;
export type PortalCloseParams = Static<typeof PortalCloseParamsSchema>;
export type PortalCloseResult = Static<typeof PortalCloseResultSchema>;
export type PortalChangedEvent = Static<typeof PortalChangedEventSchema>;
@@ -0,0 +1,12 @@
import * as portals from "./portals.js";
export const PortalProtocolSchemas = {
PortalSummary: portals.PortalSummarySchema,
PortalListParams: portals.PortalListParamsSchema,
PortalListResult: portals.PortalListResultSchema,
PortalOpenParams: portals.PortalOpenParamsSchema,
PortalOpenResult: portals.PortalOpenResultSchema,
PortalCloseParams: portals.PortalCloseParamsSchema,
PortalCloseResult: portals.PortalCloseResultSchema,
PortalChangedEvent: portals.PortalChangedEventSchema,
} as const;
@@ -8,6 +8,7 @@ import { IntegrationProtocolSchemas } from "./protocol-schema-fragment-integrati
import { NodeProtocolSchemas } from "./protocol-schema-fragment-nodes.js";
import { OperationsProtocolSchemas } from "./protocol-schema-fragment-operations.js";
import { PluginLifecycleProtocolSchemas } from "./protocol-schema-fragment-plugins-lifecycle.js";
import { PortalProtocolSchemas } from "./protocol-schema-fragment-portals.js";
import { SchedulerProtocolSchemas } from "./protocol-schema-fragment-scheduler.js";
import { SessionCollaborationProtocolSchemas } from "./protocol-schema-fragment-sessions-collaboration.js";
import { SessionCoreProtocolSchemas } from "./protocol-schema-fragment-sessions-core.js";
@@ -30,6 +31,7 @@ export const ProtocolSchemas = composeProtocolSchemaFragments([
SchedulerProtocolSchemas,
ApprovalProtocolSchemas,
PluginLifecycleProtocolSchemas,
PortalProtocolSchemas,
] as const);
export {
@@ -155,6 +155,9 @@ export const validateEnvironmentsCreateParams = compile(S.EnvironmentsCreatePara
export const validateEnvironmentsDestroyParams = compile(S.EnvironmentsDestroyParamsSchema);
export const validateEnvironmentsListParams = compile(S.EnvironmentsListParamsSchema);
export const validateEnvironmentsStatusParams = compile(S.EnvironmentsStatusParamsSchema);
export const validatePortalListParams = compile(S.PortalListParamsSchema);
export const validatePortalOpenParams = compile(S.PortalOpenParamsSchema);
export const validatePortalCloseParams = compile(S.PortalCloseParamsSchema);
export const validateWorkerDesktopObserveParams = compile(S.WorkerDesktopObserveParamsSchema);
export const validateWorkerDesktopObserveResult = compile(S.WorkerDesktopObserveResultSchema);
export const validateWorkerDesktopLaunchParams = compile(S.WorkerDesktopLaunchParamsSchema);
+2 -2
View File
@@ -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,
@@ -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.",
@@ -54,6 +54,7 @@ const CORE_TOOL_FACTORY_DESCRIPTORS = [
{ name: "create_goal", family: "openclaw" },
{ name: "subagents", family: "openclaw" },
{ name: "terminal", family: "openclaw" },
{ name: "portal", family: "openclaw" },
{ name: "transcripts", family: "openclaw" },
{ name: "tts", family: "openclaw" },
{ name: "update_goal", family: "openclaw" },
+10 -1
View File
@@ -619,16 +619,25 @@ describe("gateway client capability tool filtering", () => {
expect(hasTool(createOpenClawTools({ clientCaps: ["ui-commands"] }), "screen")).toBe(true);
});
it("omits terminal for sandboxed agents", () => {
it("omits host UI runtime tools for sandboxed agents", () => {
expect(hasTool(createOpenClawTools({ agentSessionKey: "agent:main:main" }), "terminal")).toBe(
true,
);
expect(hasTool(createOpenClawTools({ agentSessionKey: "agent:main:main" }), "portal")).toBe(
true,
);
expect(
hasTool(
createOpenClawTools({ agentSessionKey: "agent:main:main", sandboxed: true }),
"terminal",
),
).toBe(false);
expect(
hasTool(
createOpenClawTools({ agentSessionKey: "agent:main:main", sandboxed: true }),
"portal",
),
).toBe(false);
});
it("does not let tools.allow resurrect a gated tool for a channel run", () => {
+2
View File
@@ -76,6 +76,7 @@ import { createMusicGenerateTool } from "./tools/music-generate-tool.js";
import { createNodesTool } from "./tools/nodes-tool.js";
import { createOpenClawDelegateToolsForRun } from "./tools/openclaw-delegate-tool.js";
import { createPdfTool } from "./tools/pdf-tool.js";
import { createPortalTool } from "./tools/portal-tool.js";
import { createScreenTool } from "./tools/screen-tool.js";
import { createSessionStatusTool } from "./tools/session-status-tool.js";
import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
@@ -514,6 +515,7 @@ export function createOpenClawTools(
agentSessionKey: options?.runSessionKey ?? options?.agentSessionKey,
runId: options?.runId,
}),
createPortalTool(),
]),
]),
...(!embedded && taskKey && options?.taskSuggestionDeliveryMode === "gateway"
+1
View File
@@ -63,6 +63,7 @@ describe("tool-catalog", () => {
"screen",
"dashboard",
"terminal",
"portal",
"automations",
"get_goal",
"create_goal",
+8
View File
@@ -310,6 +310,14 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [
profiles: ["coding"],
includeInOpenClawGroup: true,
},
{
id: "portal",
label: "portal",
description: "Expose local web apps through the gateway",
sectionId: "ui",
profiles: ["coding"],
includeInOpenClawGroup: true,
},
{
id: "canvas",
label: "canvas",
+5
View File
@@ -70,6 +70,11 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = {
title: "Terminal",
detailKeys: ["action", "sessionId", "command", "cwd"],
},
portal: {
emoji: "🌐",
title: "Portal",
detailKeys: ["action", "port", "id", "title", "path"],
},
process: {
emoji: "🧰",
title: "Process",
+1
View File
@@ -21,6 +21,7 @@ const MUTATING_TOOL_NAMES = new Set([
// Saved transcripts predate the rename; legacy names must stay classified.
...LEGACY_AUTOMATIONS_TOOL_NAMES,
"gateway",
"portal",
"canvas",
"computer",
"mobile_ui",
+9
View File
@@ -20,6 +20,15 @@ describe("tool mutation helpers", () => {
).toBe(true);
});
it("classifies portal list as replay-safe and portal mutations as mutating", () => {
expect(isMutatingToolCall("portal", { action: "list" })).toBe(false);
expect(isReplaySafeToolCall("portal", { action: "list" })).toBe(true);
for (const action of ["open", "close"]) {
expect(isMutatingToolCall("portal", { action }), action).toBe(true);
expect(isReplaySafeToolCall("portal", { action }), action).toBe(false);
}
});
it("builds stable fingerprints for mutating calls and omits read-only calls", () => {
const writeFingerprint = buildToolMutationState(
"write",
+4
View File
@@ -362,6 +362,8 @@ export function isMutatingToolCall(toolName: string, args: unknown): boolean {
return typeof record?.model === "string" && record.model.trim().length > 0;
case "gateway":
return action == null || !GATEWAY_REPLAY_SAFE_ACTIONS.has(action);
case "portal":
return action !== "list";
case "nodes":
return action == null || !NODES_REPLAY_SAFE_ACTIONS.has(action);
default: {
@@ -413,6 +415,8 @@ export function isReplaySafeToolCall(toolName: string, args: unknown): boolean {
return action === "status";
case "gateway":
return action != null && GATEWAY_REPLAY_SAFE_ACTIONS.has(action);
case "portal":
return action === "list";
case "nodes":
return action != null && NODES_REPLAY_SAFE_ACTIONS.has(action);
default: {
+100
View File
@@ -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<string, unknown>]> = [];
const callGateway: InProcessGatewayCaller = async <T>(
method: string,
params: Record<string, unknown>,
): Promise<T> => {
calls.push([method, params]);
if (method === "portal.list") {
return { portals: [portal] } as PortalListResult as T;
}
if (method === "portal.close") {
return { closed: true } as PortalCloseResult as T;
}
return portal as T;
};
return { calls, callGateway };
}
describe("portal tool", () => {
it("uses a flat closed action schema and owner-only security gate", () => {
const tool = createPortalTool();
expect(tool.parameters).toMatchObject({
additionalProperties: false,
properties: { action: { enum: ["open", "list", "close"] } },
});
expect(Value.Check(tool.parameters, { action: "open", port: 3000, path: "/app" })).toBe(true);
expect(Value.Check(tool.parameters, { action: "open", port: 0 })).toBe(false);
expect(Value.Check(tool.parameters, { action: "open", port: 3000, path: "app" })).toBe(false);
expect(Value.Check(tool.parameters, { action: "unknown" })).toBe(false);
expect(GATEWAY_OWNER_ONLY_CORE_TOOLS).toContain("portal");
expect(DEFAULT_GATEWAY_HTTP_TOOL_DENY).toContain("portal");
});
it("maps open, list, and close through the in-process gateway caller", async () => {
const recorded = recorder();
const tool = createPortalTool({ callGateway: recorded.callGateway });
const opened = await tool.execute("open", {
action: "open",
port: 3000,
title: "App",
description: "Preview",
path: "/app",
});
const listed = await tool.execute("list", { action: "list" });
const closed = await tool.execute("close", { action: "close", id: "p3000" });
expect(recorded.calls).toEqual([
["portal.open", { port: 3000, title: "App", description: "Preview", path: "/app" }],
["portal.list", {}],
["portal.close", { id: "p3000" }],
]);
expect(opened.details).toEqual(portal);
expect(opened.content[0]).toMatchObject({
type: "text",
text: `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([]);
});
});
+104
View File
@@ -0,0 +1,104 @@
import { Type } from "typebox";
import {
PortalCloseResultSchema,
PortalListResultSchema,
PortalSummarySchema,
type PortalCloseResult,
type PortalListResult,
type PortalSummary,
} from "../../../packages/gateway-protocol/src/index.js";
import type { AgentToolResult } from "../runtime/index.js";
import type { AnyAgentTool } from "./common.js";
import {
jsonResult,
readPositiveIntegerParam,
readToolStringParam,
ToolInputError,
} from "./common.js";
import { callInProcessGatewayTool, type InProcessGatewayCaller } from "./in-process-gateway.js";
const PORTAL_ACTIONS = ["open", "list", "close"] as const;
const PortalToolSchema = Type.Object(
{
action: Type.String({ enum: [...PORTAL_ACTIONS], description: "Portal action" }),
port: Type.Optional(Type.Integer({ minimum: 1, maximum: 65_535 })),
title: Type.Optional(Type.String({ minLength: 1 })),
description: Type.Optional(Type.String()),
path: Type.Optional(Type.String({ pattern: "^/" })),
id: Type.Optional(Type.String({ minLength: 1 })),
},
{ additionalProperties: false },
);
const PortalToolOutputSchema = Type.Union([
PortalSummarySchema,
PortalListResultSchema,
PortalCloseResultSchema,
]);
type PortalToolOptions = {
callGateway?: InProcessGatewayCaller;
};
function portalResult<T>(text: string, payload: T): AgentToolResult<T> {
const result = jsonResult(payload);
return { ...result, content: [{ type: "text", text }, ...result.content] };
}
export function createPortalTool(options: PortalToolOptions = {}): AnyAgentTool {
const callGateway = options.callGateway ?? callInProcessGatewayTool;
return {
label: "Portal",
name: "portal",
description:
"Expose a local HTTP dev server through the gateway so the operator can view it live (a portal). Flow: pick a port (if the workspace has .openclaw/portals.json, use its declared entries), call action=open with that port to get the portal URL, then start the server with the exec tool (background=true) passing PORT=<port> and PUBLIC_URL=<publicUrl> in env. The proxy carries HTTP and WebSockets (hot reload works) and shows a retry page until the server listens. action=list shows active portals; action=close removes one. Portals end when the gateway restarts.",
parameters: PortalToolSchema,
outputSchema: PortalToolOutputSchema,
execute: async (_toolCallId, rawArgs) => {
const params = rawArgs as Record<string, unknown>;
const action = readToolStringParam(params, "action", { required: true });
if (action === "list") {
const result = await callGateway<PortalListResult>("portal.list", {});
return portalResult(
`${result.portals.length} active portal${result.portals.length === 1 ? "" : "s"}. The operator can see them in the Control UI Portals page.`,
result,
);
}
if (action === "close") {
const id = readToolStringParam(params, "id", { required: true });
const result = await callGateway<PortalCloseResult>("portal.close", { id });
return portalResult(
`Portal ${id} closed. The Control UI Portals page has been updated.`,
result,
);
}
if (action !== "open") {
throw new ToolInputError(`Unknown portal action: ${action}`);
}
const port = readPositiveIntegerParam(params, "port", {
max: 65_535,
message: "port must be an integer from 1 to 65535",
});
if (port === undefined) {
throw new ToolInputError("port required");
}
const title = readToolStringParam(params, "title");
const description = readToolStringParam(params, "description", { allowEmpty: true });
const path = readToolStringParam(params, "path");
if (path !== undefined && !path.startsWith("/")) {
throw new ToolInputError("path must start with /");
}
const portal = await callGateway<PortalSummary>("portal.open", {
port,
...(title !== undefined ? { title } : {}),
...(description !== undefined ? { description } : {}),
...(path !== undefined ? { path } : {}),
});
return portalResult(
`Portal available at ${portal.url}. 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,
);
},
};
}
+13
View File
@@ -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 "));
+18
View File
@@ -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'",
@@ -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) } },
);
+27 -13
View File
@@ -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 } : {}),
+5 -1
View File
@@ -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");
+5 -1
View File
@@ -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 } },
);
@@ -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", () => {
+3
View File
@@ -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>;
@@ -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<GatewayPortalService>();
const temporaryTargetServers = new Set<Server>();
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<void>((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<void>((resolve) => {
server.close(() => resolve());
server.closeAllConnections();
}),
),
);
temporaryTargetServers.clear();
targetWebSocketPath = undefined;
targetWebSocketCookie = undefined;
targetWebSocketSetCookie = undefined;
});
afterAll(async () => {
targetWss.close();
await new Promise<void>((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<number> {
const server = createServer(handler);
temporaryTargetServers.add(server);
await new Promise<void>((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<string, string>;
body?: string;
}): Promise<HttpResult> {
return await new Promise<HttpResult>((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<string, string>, 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, string>): 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<string, string>,
params: Omit<Parameters<typeof httpCall>[0], "headers">,
): Promise<HttpResult> {
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<string, string>();
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<string | undefined> = [];
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<string | undefined> = [];
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<string, string>();
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<void>((resolve) => {
unavailableTarget.listen(0, "127.0.0.1", resolve);
});
const port = (unavailableTarget.address() as AddressInfo).port;
await new Promise<void>((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<void>((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<void>((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<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", reject);
});
const echoed = new Promise<string>((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<void>((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<string, string>();
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<void>((resolve, reject) => {
ws.once("open", resolve);
ws.once("error", reject);
});
const echoed = new Promise<string>((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<void>((resolve) => {
ws.once("close", () => resolve());
ws.close();
});
});
});
+420
View File
@@ -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 =
"<!doctype html><meta charset=utf-8><title>Private portal</title>" +
"<p>This portal is private. Open it from the OpenClaw Control UI.</p>";
htmlResponse(res, 401, html, req.method === "HEAD");
}
function respondPortalWaiting(req: IncomingMessage, res: ServerResponse, targetPort: number): void {
const html =
'<!doctype html><meta charset=utf-8><meta http-equiv="refresh" content="2">' +
`<title>Waiting for app</title><p>Waiting for the app on port ${targetPort}…</p>`;
htmlResponse(res, 502, html, req.method === "HEAD");
}
function connectionHeaderTokens(headers: IncomingHttpHeaders): Set<string> {
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<Duplex>;
}): 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);
});
}
+112
View File
@@ -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<GatewayPortalService>();
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<number> {
return await new Promise<number>((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<void> {
await new Promise<void>((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}`);
});
});
+237
View File
@@ -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<Duplex>;
};
type GatewayPortalOpenParams = {
targetPort: number;
title?: string;
description?: string;
path?: string;
};
export type GatewayPortalService = {
open: (params: GatewayPortalOpenParams) => Promise<PortalOpenResult>;
list: () => PortalSummary[];
close: (id: string) => Promise<void>;
closeAll: () => Promise<void>;
};
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<void> {
await Promise.all(
servers.map(
(server) =>
new Promise<void>((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<string, PortalRuntimeEntry>();
const operations = new Map<string, Promise<void>>();
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 <T>(id: string, operation: () => Promise<T>): Promise<T> => {
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<void> => {
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<Duplex>();
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))));
},
};
}
+1
View File
@@ -85,6 +85,7 @@ const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
// 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
@@ -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,
+1
View File
@@ -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,
+15 -2
View File
@@ -26,6 +26,10 @@ describe("GATEWAY_EVENTS", () => {
expect(GATEWAY_EVENTS).toContain("skills.changed");
});
it("advertises portal replace-set updates", () => {
expect(GATEWAY_EVENTS).toContain("portal.changed");
});
it("advertises session observer digests", () => {
expect(GATEWAY_EVENTS).toContain("session.observer");
});
@@ -66,7 +70,7 @@ describe("listGatewayMethods", () => {
});
it("appends new methods after model probing without shifting older method indices", () => {
expect(listGatewayMethods().slice(-50)).toEqual([
expect(listGatewayMethods().slice(-53)).toEqual([
"models.probe",
"migrations.memory.plan",
"migrations.memory.apply",
@@ -117,6 +121,9 @@ describe("listGatewayMethods", () => {
"desktop.launch",
"device.scopes.requestUpgrade",
"device.scopes.waitUpgrade",
"portal.list",
"portal.open",
"portal.close",
]);
const methods = listGatewayMethods();
expect(methods.indexOf("node.pluginSurface.refresh")).toBe(
@@ -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", () => {
+1
View File
@@ -83,4 +83,5 @@ export const GATEWAY_EVENTS = [
"terminal.data",
"terminal.exit",
GATEWAY_EVENT_UPDATE_AVAILABLE,
"portal.changed",
];
+1
View File
@@ -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),
+198
View File
@@ -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<string, unknown>) => {
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<string, string[]>();
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"]);
});
});
+121
View File
@@ -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<GatewayRequestHandlers[string]>[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)),
);
}
},
};
@@ -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<number>;
broadcast: GatewayBroadcastFn;
@@ -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,
+4
View File
@@ -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,
@@ -556,6 +556,7 @@ export async function prepareGatewayKernelState(params: {
getWorkerIngressEndpoint: transportBridge.getWorkerIngressEndpoint,
getMcpAppSandboxPort: transportBridge.getMcpAppSandboxPort,
ensureSandboxHostPort: transportBridge.ensureSandboxHostPort,
getPortalService: transportBridge.getPortalService,
workerGatewayEndpoint,
};
}
+8
View File
@@ -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<void>;
wss: WebSocketServer;
preauthConnectionBudget: PreauthConnectionBudget;
portalService: GatewayPortalService;
getWorkerIngressEndpoint: () => { host: "127.0.0.1"; port: number } | undefined;
getMcpAppSandboxPort: () => number | undefined;
ensureSandboxHostPort: () => Promise<number>;
@@ -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
+1
View File
@@ -11,6 +11,7 @@ export function createGatewayTransportBridge() {
current = transport;
},
current: () => current,
getPortalService: () => current?.portalService,
getWorkerIngressEndpoint: () => current?.getWorkerIngressEndpoint(),
getMcpAppSandboxPort: () => current?.getMcpAppSandboxPort(),
ensureSandboxHostPort: async () => {
@@ -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",
+3
View File
@@ -24,6 +24,8 @@ export const DEFAULT_GATEWAY_HTTP_TOOL_DENY = [
"apply_patch",
// Agent-owned host terminal — interactive RCE surface
"terminal",
// Local HTTP exposure can publish arbitrary workspace applications.
"portal",
// Session orchestration — spawning agents remotely is RCE
"sessions_spawn",
// Cross-session injection — message injection across sessions
@@ -61,6 +63,7 @@ export const GATEWAY_OWNER_ONLY_CORE_TOOLS = [
"sessions",
"screen",
"terminal",
"portal",
"conversations_list",
"conversations_send",
"conversations_turn",
+8
View File
@@ -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",
+17
View File
@@ -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);
});
+3
View File
@@ -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<NavigationRouteId, { titleKey: string; subtitleKey
agents: { titleKey: "tabs.agents", subtitleKey: "subtitles.agents" },
activity: { titleKey: "tabs.activity", subtitleKey: "subtitles.activity" },
apps: { titleKey: "tabs.apps", subtitleKey: "subtitles.apps" },
portals: { titleKey: "tabs.portals", subtitleKey: "subtitles.portals" },
approvals: { titleKey: "tabs.approvals", subtitleKey: "subtitles.approvals" },
workboard: { titleKey: "tabs.workboard", subtitleKey: "subtitles.workboard" },
worktrees: { titleKey: "tabs.worktrees", subtitleKey: "subtitles.worktrees" },
+5
View File
@@ -102,6 +102,11 @@ describe("Dynamic route startup bridge", () => {
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 }) => {
+1
View File
@@ -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" },
+1
View File
@@ -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());
});
+2
View File
@@ -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,
+23
View File
@@ -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:
@@ -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);
});
});
@@ -0,0 +1,13 @@
const PORTAL_REACHABILITY_TIMEOUT_MS = 4_000;
export async function probePortalReachable(url: string): Promise<boolean> {
try {
await fetch(url, {
mode: "no-cors",
signal: AbortSignal.timeout(PORTAL_REACHABILITY_TIMEOUT_MS),
});
return true;
} catch {
return false;
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { PortalSummary } from "@openclaw/gateway-protocol";
import { resolveGatewayHttpOrigin } from "../../components/sandbox-host.ts";
export function resolvePortalUrl(
portal: Pick<PortalSummary, "listenPort" | "path"> & { 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;
}
+199
View File
@@ -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<boolean>>());
vi.mock("./portal-reachability.ts", () => ({ probePortalReachable }));
import "./portals-page.ts";
type PortalsPageTestElement = HTMLElement & {
context: ApplicationContext;
updateComplete: Promise<boolean>;
};
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<string, unknown>) => Promise<unknown>,
) {
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<HTMLButtonElement>(".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");
});
});
+381
View File
@@ -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<string, boolean>();
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<PortalListResult>("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<PortalCloseResult>("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`
<section class="portals-empty" role="status" aria-live="polite">
${this.loading && !this.loaded
? html`<div class="portals-empty__title">${t("portalsPage.loading")}</div>`
: html`
<div class="portals-empty__title">${t("portalsPage.emptyHint")}</div>
<div class="portals-empty__prompts">
<span>${t("portalsPage.promptShow")}</span>
<span>${t("portalsPage.promptStart")}</span>
<span>${t("portalsPage.promptMakeAvailable")}</span>
</div>
`}
${unsupported
? html`<div class="portals-empty__note">${t("portalsPage.unsupported")}</div>`
: nothing}
${this.error ? html`<div class="callout danger">${this.error}</div>` : nothing}
</section>
`;
}
private renderPortal(portal: PortalSummary) {
if (!portal.tokenQuery) {
return html`
<section class="portals-preview">
<div class="portals-preview__notice" role="status">
<div class="portals-preview__notice-title">
${t("portalsPage.writeAccessRequiredTitle")}
</div>
<p>${t("portalsPage.writeAccessRequiredBody")}</p>
</div>
</section>
`;
}
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`
<section class="portals-preview">
<header class="portals-preview__header">
<a
class="portals-preview__url"
href=${portalUrl}
target="_blank"
rel="noopener noreferrer"
title=${portalUrl}
>
<span>${portalUrl}</span>
${icon("externalLink")}
<span class="sr-only">${t("portalsPage.openNewTab")}</span>
</a>
<button
class="btn btn--icon btn--ghost portals-preview__close"
type="button"
title=${t("portalsPage.closePortal", { title: portal.title })}
aria-label=${t("portalsPage.closePortal", { title: portal.title })}
?disabled=${!this.canClosePortal || this.closingPortalId === portal.id}
@click=${() => void this.closePortal(portal)}
>
${icon("x")}
</button>
</header>
${this.error
? html`<div class="callout danger portals-preview__error">${this.error}</div>`
: nothing}
${probeStatus === "probing"
? html`
<div class="portals-empty portals-preview__state" role="status" aria-live="polite">
<div class="portals-empty__title">${t("portalsPage.loading")}</div>
</div>
`
: probeStatus === "unreachable"
? html`
<div class="portals-preview__notice" role="status">
<div class="portals-preview__notice-title">
${t("portalsPage.unreachableTitle")}
</div>
<p>${t("portalsPage.unreachableBody")}</p>
<a
class="portals-preview__notice-url"
href=${portalUrl}
target="_blank"
rel="noopener noreferrer"
>${portalUrl}</a
>
<button
class="btn"
type="button"
@click=${() => this.ensurePortalProbe(portal, true)}
>
${t("portalsPage.retry")}
</button>
</div>
`
: keyed(
frameKey,
html`<iframe
${ref((element) => {
if (element instanceof HTMLIFrameElement && !element.hasAttribute("src")) {
element.setAttribute("src", portalUrl);
}
})}
class="portals-preview__frame"
title=${t("portalsPage.previewTitle", { title: portal.title })}
referrerpolicy="no-referrer"
sandbox=${PORTAL_FRAME_SANDBOX}
></iframe>`,
)}
</section>
`;
}
override render() {
const selectedPortal =
this.portals.find((portal) => portal.id === this.selectedPortalId) ?? this.portals[0];
return html`
<section class="content-header content-header--page">
<div>
<div class="page-title">${titleForRoute("portals")}</div>
</div>
</section>
${selectedPortal
? html`
<section class="portals-layout">
<aside class="portals-rail" aria-label=${t("portalsPage.listLabel")}>
${this.portals.map(
(portal) => html`
<button
class="portals-rail__item ${portal.id === selectedPortal.id ? "active" : ""}"
type="button"
aria-current=${portal.id === selectedPortal.id ? "true" : nothing}
@click=${() => this.selectPortal(portal)}
>
<span class="portals-rail__title">${portal.title}</span>
<span class="portals-rail__port"
>${t("portalsPage.portLabel", { port: String(portal.port) })}</span
>
${portal.description
? html`<span class="portals-rail__description">${portal.description}</span>`
: nothing}
</button>
`,
)}
</aside>
${this.renderPortal(selectedPortal)}
</section>
`
: this.renderEmptyState()}
`;
}
}
if (!customElements.get("openclaw-portals-page")) {
customElements.define("openclaw-portals-page", PortalsPage);
}
+215
View File
@@ -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;
}
}
+12
View File
@@ -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`<openclaw-portals-page></openclaw-portals-page>`,
})),
});