diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index d2e3dc716327..672557879533 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -19229,8 +19229,8 @@ public struct PortalSummary: Codable, Sendable { public let title: String public let port: Int public let listenport: Int - public let tokenquery: String - public let url: String + public let tokenquery: String? + public let url: String? public let publicurl: String public let path: String? public let description: String? @@ -19241,8 +19241,8 @@ public struct PortalSummary: Codable, Sendable { title: String, port: Int, listenport: Int, - tokenquery: String, - url: String, + tokenquery: String? = nil, + url: String? = nil, publicurl: String, path: String? = nil, description: String? = nil, @@ -19321,8 +19321,8 @@ public struct PortalOpenResult: Codable, Sendable { public let title: String public let port: Int public let listenport: Int - public let tokenquery: String - public let url: String + public let tokenquery: String? + public let url: String? public let publicurl: String public let path: String? public let description: String? @@ -19333,8 +19333,8 @@ public struct PortalOpenResult: Codable, Sendable { title: String, port: Int, listenport: Int, - tokenquery: String, - url: String, + tokenquery: String? = nil, + url: String? = nil, publicurl: String, path: String? = nil, description: String? = nil, diff --git a/packages/gateway-protocol/src/schema/portals.test.ts b/packages/gateway-protocol/src/schema/portals.test.ts index b84097f08d9e..bbf2f95bc244 100644 --- a/packages/gateway-protocol/src/schema/portals.test.ts +++ b/packages/gateway-protocol/src/schema/portals.test.ts @@ -43,6 +43,8 @@ describe("portal protocol schemas", () => { 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(PortalCloseResultSchema, { closed: true })).toBe(true); expect(Value.Check(PortalChangedEventSchema, { portals: [portal] })).toBe(true); const { publicUrl: _publicUrl, ...missingPublicUrl } = portal; diff --git a/packages/gateway-protocol/src/schema/portals.ts b/packages/gateway-protocol/src/schema/portals.ts index aba2000cb4f0..e94fb604904f 100644 --- a/packages/gateway-protocol/src/schema/portals.ts +++ b/packages/gateway-protocol/src/schema/portals.ts @@ -7,8 +7,8 @@ export const PortalSummarySchema = closedObject({ title: NonEmptyString, port: Type.Integer({ minimum: 1, maximum: 65_535 }), listenPort: Type.Integer({ minimum: 1, maximum: 65_535 }), - tokenQuery: NonEmptyString, - url: NonEmptyString, + tokenQuery: Type.Optional(NonEmptyString), + url: Type.Optional(NonEmptyString), publicUrl: NonEmptyString, path: Type.Optional(Type.String({ pattern: "^/" })), description: Type.Optional(Type.String()), diff --git a/src/gateway/server-methods/portals.test.ts b/src/gateway/server-methods/portals.test.ts index f131a3ffca97..f0bc842523f7 100644 --- a/src/gateway/server-methods/portals.test.ts +++ b/src/gateway/server-methods/portals.test.ts @@ -17,13 +17,14 @@ const portal: PortalSummary = { createdAtMs: 1, }; -function harness(service?: GatewayPortalService) { +function harness(service?: GatewayPortalService, scopes = ["operator.write"]) { const broadcast = vi.fn(); const invoke = async (method: keyof typeof portalHandlers, params: Record) => { const respond = vi.fn(); await portalHandlers[method]!({ params, respond, + client: { connect: { scopes } } as never, context: { portalService: service, broadcast } as never, } as never); return respond; @@ -66,7 +67,18 @@ describe("portal gateway methods", () => { expect(service.open).toHaveBeenCalledWith({ targetPort: 3000, title: "App" }); expect(broadcast).toHaveBeenLastCalledWith( "portal.changed", - { portals: [portal] }, + { + 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([ @@ -81,6 +93,35 @@ describe("portal gateway methods", () => { ); }); + 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(() => []), diff --git a/src/gateway/server-methods/portals.ts b/src/gateway/server-methods/portals.ts index b2a1ad1911a0..047de2ac8510 100644 --- a/src/gateway/server-methods/portals.ts +++ b/src/gateway/server-methods/portals.ts @@ -4,10 +4,12 @@ import { 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 { @@ -32,8 +34,13 @@ function requirePortalService( 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 }) => { + "portal.list": ({ params, respond, context, client }) => { if (!validatePortalListParams(params)) { invalidParams("portal.list", validatePortalListParams.errors, respond); return; @@ -42,7 +49,18 @@ export const portalHandlers: GatewayRequestHandlers = { if (!service) { return; } - respond(true, { portals: service.list() }, undefined); + 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)) { @@ -61,7 +79,11 @@ export const portalHandlers: GatewayRequestHandlers = { ...(request.description !== undefined ? { description: request.description } : {}), ...(request.path !== undefined ? { path: request.path } : {}), }); - context.broadcast("portal.changed", { portals: service.list() }, { dropIfSlow: true }); + context.broadcast( + "portal.changed", + { portals: service.list().map(redactPortalSummary) }, + { dropIfSlow: true }, + ); respond(true, portal, undefined); } catch (error) { respond( @@ -82,7 +104,11 @@ export const portalHandlers: GatewayRequestHandlers = { } try { await service.close((params as PortalCloseParams).id); - context.broadcast("portal.changed", { portals: service.list() }, { dropIfSlow: true }); + context.broadcast( + "portal.changed", + { portals: service.list().map(redactPortalSummary) }, + { dropIfSlow: true }, + ); respond(true, { closed: true }, undefined); } catch (error) { respond( diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 592177ad84da..529508589f0a 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -2145,6 +2145,8 @@ export const en: TranslationMap = { 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: { diff --git a/ui/src/pages/portals/portal-url.ts b/ui/src/pages/portals/portal-url.ts index e52fcb07a2ab..06d4088545a7 100644 --- a/ui/src/pages/portals/portal-url.ts +++ b/ui/src/pages/portals/portal-url.ts @@ -2,7 +2,7 @@ import type { PortalSummary } from "@openclaw/gateway-protocol"; import { resolveGatewayHttpOrigin } from "../../components/sandbox-host.ts"; export function resolvePortalUrl( - portal: Pick, + portal: Pick & { tokenQuery: string }, gatewayUrl: string, hostOrigin: string, ): string { diff --git a/ui/src/pages/portals/portals-page.test.ts b/ui/src/pages/portals/portals-page.test.ts index b8bea2ef0396..fd2e176d167d 100644 --- a/ui/src/pages/portals/portals-page.test.ts +++ b/ui/src/pages/portals/portals-page.test.ts @@ -91,7 +91,7 @@ beforeEach(() => { }); describe("PortalsPage", () => { - it("renders the portal list and applies full replacement events", async () => { + 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; @@ -122,9 +122,25 @@ describe("PortalsPage", () => { source.emitPortals([]); await vi.waitFor(() => { - expect(page.querySelector(".portals-rail__item")).toBeNull(); - expect(page.textContent).toContain("Ask the agent to start a portal:"); + 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 () => { diff --git a/ui/src/pages/portals/portals-page.ts b/ui/src/pages/portals/portals-page.ts index 38c8a8719439..1968a85b93d0 100644 --- a/ui/src/pages/portals/portals-page.ts +++ b/ui/src/pages/portals/portals-page.ts @@ -1,6 +1,5 @@ import { consume } from "@lit/context"; import type { - PortalChangedEvent, PortalCloseResult, PortalListResult, PortalSummary, @@ -63,10 +62,7 @@ class PortalsPage extends OpenClawLightDomElement { ) { return; } - const portals = (event.payload as Partial | null)?.portals; - if (Array.isArray(portals)) { - this.applyPortalSet(portals); - } + void this.loadPortals(); }), ); @@ -117,16 +113,22 @@ class PortalsPage extends OpenClawLightDomElement { } } - private portalUrl(portal: PortalSummary): string { + private portalUrl(portal: PortalSummary, tokenQuery: string): string { return resolvePortalUrl( - portal, + { ...portal, tokenQuery }, this.context.gateway.connection.gatewayUrl, window.location.origin, ); } private ensurePortalProbe(portal: PortalSummary, force = false) { - const url = this.portalUrl(portal); + 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; @@ -243,7 +245,19 @@ class PortalsPage extends OpenClawLightDomElement { } private renderPortal(portal: PortalSummary) { - const portalUrl = this.portalUrl(portal); + if (!portal.tokenQuery) { + return html` +
+
+
+ ${t("portalsPage.writeAccessRequiredTitle")} +
+

${t("portalsPage.writeAccessRequiredBody")}

+
+
+ `; + } + const portalUrl = this.portalUrl(portal, portal.tokenQuery); const frameKey = `${portal.id}\u0000${portalUrl}`; const probeStatus = this.portalProbeState?.key === frameKey ? this.portalProbeState.status : "probing";