From df707a96702d8c0c77fc9f45e432ab4e9a7c04af Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 06:58:30 -0700 Subject: [PATCH] feat: view this machine in the Desktop panel (#122545) * feat(gateway): add gateway-host desktop source behind desktop.host lab Introduce the host as a first-class desktop source so operators can view the machine OpenClaw runs on, not just cloud-worker environments: - protocol: desktop.observe / desktop.launch with a discriminated DesktopSource union (host | environment) plus an additive auth hint; EnvironmentSummary gains a top-level desktop flag - config: desktop.host { enabled, port?, passwordFile? }, Labs-gated - rfb-probe: pure RFB version/security-type parser used to detect an already-running loopback VNC server and classify its auth - host-source: attaches to 127.0.0.1:, refuses unauthenticated (None) and unsupported (VeNCrypt) servers, and refuses ARD with the supported alternative until the macOS milestone - host-guidance: per-OS enablement text so no path dead-ends - doctor + status report host desktop availability and auth type only worker.desktop.observe/launch stay as delegating aliases with identical behavior. Also drops the now-unused WorkerDesktopTunnels type export. Live-verified against macOS Screen Sharing: probe reads RFB 003.889, returns security types [30,33,36,35], classifies ard-account. * test(gateway): probe RFB handshakes through the socket boundary The probe's banner and security-offer parsers were exported solely so unit tests could call them, which the dead-export gate rejects and which tests internals rather than behavior. Keep them module-local and drive the probe through a scripted loopback server instead. The boundary tests also cover what pure-function vectors could not: handshakes split across packets, legacy RFB 3.3 single-word security, server-rejected handshakes, early hangups, and connect timeouts. * feat(ui): let the Desktop panel view this machine, not just cloud workers The Desktop panel was gated on a cloud-worker session placement, so an operator running OpenClaw locally had no way to see the machine hosting their main session even with a VNC server running on it. Availability now follows the advertised desktop.observe method plus operator.admin instead of session placement, and the picker lists every environment whose summary reports a desktop, with the gateway row shown as "This machine". Sources are passed to the generic desktop.observe / desktop.launch RPCs; the app launcher stays worker-only. When a host attach needs a password the gateway did not supply, the panel prompts and keeps the value in memory for that connection only. Adds the hostDesktop Labs toggle for desktop.host.enabled. * fix(scripts): keep the env-var ratchet usable in shallow checkouts The env-var budget check resolved its base ref, then hard-failed when `git merge-base` found no shared ancestor. Shallow clones and grafted agent checkouts resolve origin/main but truncate the history behind it, so an advisory growth ratchet took down the whole check:changed gate with "Could not resolve env-var count merge base for: origin/main". Only the growth comparison needs a baseline, and the script already has a no-baseline path. Treat git's exit 1 with empty output (no shared ancestor) as that case and say so on stderr; a genuine failure still exits 128 and still throws, and the absolute count-vs-budget check runs either way. * test(ui): measure the inline-code chip against its line box The inline-code spacing test compared the chip's height to a prose text rect, so it silently measured the monospace font's default line spacing. That is ~17px on macOS and several px shorter on Linux, so the assertion passed on CI and failed locally at 4.5 against a 3.75 bound -- after the bound had already been widened once to chase browser font metrics. Compare the chip to the paragraph's CSS line box instead, which is what "the chip must not disrupt the line" actually means and is platform independent. The horizontal gap stays as-is: it is em-derived padding plus border, and it is the assertion that catches detached punctuation. Verified both directions on macOS: the file is fully green, and restoring the pre-fix 0.15em/0.35em padding still fails the gap assertion at 5.41. * feat(gateway): view macOS Screen Sharing from the Desktop panel Modern macOS only offers ARD account authentication for Screen Sharing, so the host desktop source refused every Mac. The Gateway now performs the ARD handshake itself against the loopback server and hands the browser a plain RFB 003.008 no-auth handshake, so the operator's macOS account password authenticates the desktop without ever reaching the browser, the observe result, a URL, or a log. - rfb-preauth: ARD (type 30) Diffie-Hellman with MD5-derived AES-128-ECB credentials, and VncAuth (type 2) bit-reversed DES, both under a single 10s negotiation deadline; Apple's RFB 003.889 maps to 3.8 - observe-bridge: runs pre-auth before splicing and starts the view-only filter at clientInit, since the browser handshake is consumed here; worker tokens keep the original version start phase - host-source: attaches ARD, requiring per-observation credentials that live only in the one-shot observer token and are dropped after use - doctor: offers an explicitly confirmed sudo launchctl repair when Screen Sharing is off, and prints the System Settings path otherwise Live-verified against this Mac's Screen Sharing: the DH exchange and credential framing are accepted and the server returns SecurityResult. The VncAuth DES vector is confirmed against OpenSSL independently. --- .../openclaw/app/gateway/GatewayProtocol.kt | 2 + .../OpenClawProtocol/GatewayModels.swift | 68 +++ docs/.generated/config-baseline.counts.json | 2 +- docs/.generated/config-baseline.sha256 | 4 +- .../plugin-sdk-api-baseline/account-core.json | 2 +- .../account-helpers.json | 2 +- .../account-resolution.json | 2 +- .../agent-harness-runtime.json | 2 +- .../agent-harness.json | 2 +- .../agent-media-payload.json | 2 +- .../agent-runtime.json | 2 +- .../agent-scope-runtime.json | 2 +- .../allowlist-config-edit.json | 2 +- .../approval-auth-runtime.json | 2 +- .../approval-client-runtime.json | 2 +- .../approval-delivery-runtime.json | 2 +- .../approval-gateway-runtime.json | 2 +- .../approval-handler-adapter-runtime.json | 2 +- .../approval-handler-runtime.json | 2 +- .../approval-native-runtime.json | 2 +- .../approval-runtime.json | 2 +- .../channel-config-helpers.json | 2 +- .../channel-contract.json | 2 +- .../plugin-sdk-api-baseline/channel-core.json | 2 +- .../channel-dm-policy.json | 2 +- .../channel-entry-contract.json | 2 +- .../channel-feedback.json | 2 +- .../channel-inbound-debounce.json | 2 +- .../channel-inbound.json | 2 +- .../channel-ingress-runtime.json | 2 +- .../channel-message.json | 2 +- .../channel-outbound.json | 2 +- .../channel-pairing.json | 2 +- .../channel-plugin-common.json | 2 +- .../channel-policy.json | 2 +- .../channel-reply-pipeline.json | 2 +- .../channel-secret-basic-runtime.json | 2 +- .../channel-secret-runtime.json | 2 +- .../channel-send-result.json | 2 +- .../channel-setup.json | 2 +- .../command-auth-native.json | 2 +- .../plugin-sdk-api-baseline/command-auth.json | 2 +- .../command-detection.json | 2 +- .../command-status.json | 2 +- .../config-contracts.json | 2 +- .../config-mutation.json | 2 +- .../config-runtime.json | 2 +- .../conversation-runtime.json | 2 +- .../plugin-sdk-api-baseline/core.json | 2 +- .../diagnostic-runtime.json | 2 +- .../directory-runtime.json | 2 +- .../plugin-sdk-api-baseline/discord.json | 2 +- .../extension-shared.json | 2 +- .../gateway-runtime.json | 2 +- .../plugin-sdk-api-baseline/health.json | 2 +- .../plugin-sdk-api-baseline/hook-runtime.json | 2 +- .../inbound-reply-dispatch.json | 2 +- .../infra-runtime.json | 2 +- .../plugin-sdk-api-baseline/logging-core.json | 2 +- .../media-local-roots.json | 2 +- .../media-runtime.json | 2 +- .../media-understanding-runtime.json | 2 +- .../media-understanding.json | 2 +- .../meeting-runtime.json | 2 +- .../memory-core-host-engine-foundation.json | 2 +- .../memory-host-core.json | 2 +- .../model-session-runtime.json | 2 +- .../models-provider-runtime.json | 2 +- .../native-command-config-runtime.json | 2 +- .../native-command-registry.json | 2 +- .../plugin-command-runtime.json | 2 +- .../plugin-config-runtime.json | 2 +- .../plugin-sdk-api-baseline/plugin-entry.json | 2 +- .../plugin-runtime.json | 2 +- .../provider-auth.json | 2 +- .../provider-catalog-runtime.json | 2 +- .../question-gateway-runtime.json | 2 +- .../reply-chunking.json | 2 +- .../reply-dispatch-runtime.json | 2 +- .../reply-payload.json | 2 +- .../reply-runtime.json | 2 +- .../plugin-sdk-api-baseline/routing.json | 2 +- .../runtime-config-snapshot.json | 2 +- .../runtime-store.json | 2 +- .../plugin-sdk-api-baseline/runtime.json | 2 +- .../secret-input-runtime.json | 2 +- .../secret-ref-runtime.json | 2 +- .../security-runtime.json | 2 +- .../session-catalog.json | 2 +- .../session-store-runtime.json | 2 +- .../setup-runtime.json | 2 +- .../plugin-sdk-api-baseline/setup.json | 2 +- .../skill-commands-runtime.json | 2 +- .../speech-settings.json | 2 +- .../plugin-sdk-api-baseline/ssrf-policy.json | 2 +- .../plugin-sdk-api-baseline/ssrf-runtime.json | 2 +- .../status-helpers.json | 2 +- .../telegram-account.json | 2 +- .../plugin-sdk-api-baseline/text-runtime.json | 2 +- .../plugin-sdk-api-baseline/tool-plugin.json | 2 +- .../webhook-ingress.json | 2 +- .../webhook-request-guards.json | 2 +- docs/gateway/configuration-reference.md | 41 ++ packages/gateway-protocol/src/index.ts | 20 +- .../gateway-protocol/src/schema-modules.ts | 1 + .../src/schema/desktop.test.ts | 70 +++ .../gateway-protocol/src/schema/desktop.ts | 49 ++ .../src/schema/environments.ts | 1 + .../protocol-schema-fragment-agent-control.ts | 5 + .../src/sessions-patch-result.ts | 14 + .../src/validator-registry.ts | 3 + scripts/check-env-var-count.mts | 9 + scripts/check-protocol-registry.mts | 4 +- src/cli/daemon-cli/status.gather.ts | 7 + src/cli/daemon-cli/status.print.test.ts | 20 + src/cli/daemon-cli/status.print.ts | 10 + src/commands/doctor-host-desktop.test.ts | 150 +++++++ src/commands/doctor-host-desktop.ts | 91 ++++ src/commands/status-overview-rows.test.ts | 1 + src/commands/status-overview-rows.ts | 10 + src/config/schema.help.core.ts | 2 + src/config/schema.hints.ts | 2 + src/config/schema.labels.ts | 2 + src/config/schema.tiers.ts | 2 +- src/config/types.desktop.ts | 15 + src/config/types.openclaw.ts | 3 + src/config/types.ts | 1 + src/config/zod-schema.desktop.test.ts | 35 ++ src/config/zod-schema.desktop.ts | 68 +++ src/config/zod-schema.root-shape.ts | 2 + ...tor-health-contribution-runners.gateway.ts | 5 + .../doctor-health-contributions-final.ts | 15 + src/gateway/desktop/host-guidance.ts | 16 + .../desktop/host-observe.integration.test.ts | 190 ++++++++ src/gateway/desktop/host-source-errors.ts | 15 + src/gateway/desktop/host-source.test.ts | 167 +++++++ src/gateway/desktop/host-source.ts | 249 ++++++++++ src/gateway/desktop/observe-bridge.test.ts | 23 +- src/gateway/desktop/observe-bridge.ts | 217 +++++++-- src/gateway/desktop/rfb-preauth.test.ts | 295 ++++++++++++ src/gateway/desktop/rfb-preauth.ts | 424 ++++++++++++++++++ src/gateway/desktop/rfb-probe.test.ts | 158 +++++++ src/gateway/desktop/rfb-probe.ts | 205 +++++++++ .../desktop/rfb-view-only-filter.test.ts | 10 + src/gateway/desktop/rfb-view-only-filter.ts | 6 +- src/gateway/desktop/session-registry.ts | 1 + .../methods/core-descriptors.since.test.ts | 2 + src/gateway/methods/core-descriptors.ts | 2 + src/gateway/server-core-runtime.ts | 9 +- src/gateway/server-kernel-request-runtime.ts | 2 + src/gateway/server-methods-list.test.ts | 10 +- .../environments.desktop.test.ts | 168 +++++++ .../server-methods/environments.test.ts | 16 +- src/gateway/server-methods/environments.ts | 236 +++++++--- src/gateway/server-methods/shared-types.ts | 2 + src/gateway/server-request-context.ts | 2 + src/gateway/server-runtime-state-prepare.ts | 35 +- ...erver.worker-desktop-advertisement.test.ts | 19 + src/status/summary.ts | 4 + src/status/types.ts | 1 + test/scripts/check-env-var-count.test.ts | 34 ++ ui/src/app/app-host.dock-suppression.test.ts | 15 +- ui/src/app/app-host.ts | 4 +- ui/src/app/app-shell-chrome.ts | 10 +- ui/src/app/app-shell-view.ts | 4 +- .../components/desktop/desktop-client.test.ts | 10 +- ui/src/components/desktop/desktop-client.ts | 6 +- .../desktop/desktop-panel-credentials.ts | 19 + .../desktop/desktop-panel-styles.ts | 164 +++++++ ui/src/components/desktop/desktop-panel.ts | 389 ++++++++-------- ui/src/e2e/desktop-panel.e2e.test.ts | 258 +++++++++-- ui/src/i18n/locales/en.ts | 20 +- ui/src/pages/chat/chat-pane-header.ts | 2 +- ui/src/pages/chat/chat-pane-terminal.test.ts | 12 +- .../chat/chat-responsive.browser.test.ts | 19 +- ui/src/pages/cron/cron-page.test.ts | 24 +- ui/src/pages/labs/labs-page.test.ts | 10 +- ui/src/pages/labs/labs-registry.ts | 15 + 178 files changed, 3906 insertions(+), 523 deletions(-) create mode 100644 packages/gateway-protocol/src/schema/desktop.test.ts create mode 100644 packages/gateway-protocol/src/schema/desktop.ts create mode 100644 packages/gateway-protocol/src/sessions-patch-result.ts create mode 100644 src/commands/doctor-host-desktop.test.ts create mode 100644 src/commands/doctor-host-desktop.ts create mode 100644 src/config/types.desktop.ts create mode 100644 src/config/zod-schema.desktop.test.ts create mode 100644 src/config/zod-schema.desktop.ts create mode 100644 src/gateway/desktop/host-guidance.ts create mode 100644 src/gateway/desktop/host-observe.integration.test.ts create mode 100644 src/gateway/desktop/host-source-errors.ts create mode 100644 src/gateway/desktop/host-source.test.ts create mode 100644 src/gateway/desktop/host-source.ts create mode 100644 src/gateway/desktop/rfb-preauth.test.ts create mode 100644 src/gateway/desktop/rfb-preauth.ts create mode 100644 src/gateway/desktop/rfb-probe.test.ts create mode 100644 src/gateway/desktop/rfb-probe.ts create mode 100644 src/gateway/server-methods/environments.desktop.test.ts create mode 100644 ui/src/components/desktop/desktop-panel-credentials.ts create mode 100644 ui/src/components/desktop/desktop-panel-styles.ts diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index 0e838d5cf753..b2f6af8e775d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -572,6 +572,8 @@ enum class GatewayMethod( UsersPrefsSet("users.prefs.set"), ProjectsAdd("projects.add"), ProjectsSearchRemote("projects.searchRemote"), + DesktopObserve("desktop.observe"), + DesktopLaunch("desktop.launch"), } enum class GatewayEvent( diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index b6843d492451..a6f19816c7df 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1908,6 +1908,7 @@ public struct EnvironmentSummary: Codable, Sendable { public let sessionhost: Bool? public let trust: String? public let capabilities: [String]? + public let desktop: Bool? public let worker: WorkerEnvironmentMetadata? public init( @@ -1919,6 +1920,7 @@ public struct EnvironmentSummary: Codable, Sendable { sessionhost: Bool? = nil, trust: String? = nil, capabilities: [String]? = nil, + desktop: Bool? = nil, worker: WorkerEnvironmentMetadata? = nil) { self.id = id @@ -1929,6 +1931,7 @@ public struct EnvironmentSummary: Codable, Sendable { self.sessionhost = sessionhost self.trust = trust self.capabilities = capabilities + self.desktop = desktop self.worker = worker } @@ -1941,6 +1944,7 @@ public struct EnvironmentSummary: Codable, Sendable { case sessionhost = "sessionHost" case trust case capabilities + case desktop case worker } } @@ -1972,6 +1976,7 @@ public struct EnvironmentsCreateResult: Codable, Sendable { public let sessionhost: Bool? public let trust: String? public let capabilities: [String]? + public let desktop: Bool? public let worker: WorkerEnvironmentMetadata? public init( @@ -1983,6 +1988,7 @@ public struct EnvironmentsCreateResult: Codable, Sendable { sessionhost: Bool? = nil, trust: String? = nil, capabilities: [String]? = nil, + desktop: Bool? = nil, worker: WorkerEnvironmentMetadata? = nil) { self.id = id @@ -1993,6 +1999,7 @@ public struct EnvironmentsCreateResult: Codable, Sendable { self.sessionhost = sessionhost self.trust = trust self.capabilities = capabilities + self.desktop = desktop self.worker = worker } @@ -2005,6 +2012,7 @@ public struct EnvironmentsCreateResult: Codable, Sendable { case sessionhost = "sessionHost" case trust case capabilities + case desktop case worker } } @@ -2036,6 +2044,7 @@ public struct EnvironmentsDestroyResult: Codable, Sendable { public let sessionhost: Bool? public let trust: String? public let capabilities: [String]? + public let desktop: Bool? public let worker: WorkerEnvironmentMetadata? public init( @@ -2047,6 +2056,7 @@ public struct EnvironmentsDestroyResult: Codable, Sendable { sessionhost: Bool? = nil, trust: String? = nil, capabilities: [String]? = nil, + desktop: Bool? = nil, worker: WorkerEnvironmentMetadata? = nil) { self.id = id @@ -2057,6 +2067,7 @@ public struct EnvironmentsDestroyResult: Codable, Sendable { self.sessionhost = sessionhost self.trust = trust self.capabilities = capabilities + self.desktop = desktop self.worker = worker } @@ -2069,6 +2080,7 @@ public struct EnvironmentsDestroyResult: Codable, Sendable { case sessionhost = "sessionHost" case trust case capabilities + case desktop case worker } } @@ -2116,6 +2128,7 @@ public struct EnvironmentsStatusResult: Codable, Sendable { public let sessionhost: Bool? public let trust: String? public let capabilities: [String]? + public let desktop: Bool? public let worker: WorkerEnvironmentMetadata? public init( @@ -2127,6 +2140,7 @@ public struct EnvironmentsStatusResult: Codable, Sendable { sessionhost: Bool? = nil, trust: String? = nil, capabilities: [String]? = nil, + desktop: Bool? = nil, worker: WorkerEnvironmentMetadata? = nil) { self.id = id @@ -2137,6 +2151,7 @@ public struct EnvironmentsStatusResult: Codable, Sendable { self.sessionhost = sessionhost self.trust = trust self.capabilities = capabilities + self.desktop = desktop self.worker = worker } @@ -2149,6 +2164,7 @@ public struct EnvironmentsStatusResult: Codable, Sendable { case sessionhost = "sessionHost" case trust case capabilities + case desktop case worker } } @@ -2281,6 +2297,58 @@ public struct ProjectSummary: Codable, Sendable { } } +public struct DesktopObserveResult: Codable, Sendable { + public let transport: String + public let wspath: String + public let expiresatms: Int + public let control: Bool + public let vncpassword: String? + public let auth: String? + + public init( + transport: String, + wspath: String, + expiresatms: Int, + control: Bool, + vncpassword: String? = nil, + auth: String? = nil) + { + self.transport = transport + self.wspath = wspath + self.expiresatms = expiresatms + self.control = control + self.vncpassword = vncpassword + self.auth = auth + } + + private enum CodingKeys: String, CodingKey { + case transport + case wspath = "wsPath" + case expiresatms = "expiresAtMs" + case control + case vncpassword = "vncPassword" + case auth + } +} + +public struct DesktopLaunchParams: Codable, Sendable { + public let source: [String: AnyCodable] + public let app: WorkerDesktopAppId + + public init( + source: [String: AnyCodable], + app: WorkerDesktopAppId) + { + self.source = source + self.app = app + } + + private enum CodingKeys: String, CodingKey { + case source + case app + } +} + public struct SystemInfoParams: Codable, Sendable {} public struct SystemInfoResult: Codable, Sendable { diff --git a/docs/.generated/config-baseline.counts.json b/docs/.generated/config-baseline.counts.json index 0c97e43b133d..5651165dd46a 100644 --- a/docs/.generated/config-baseline.counts.json +++ b/docs/.generated/config-baseline.counts.json @@ -1,5 +1,5 @@ { - "core": 2295, + "core": 2300, "channel": 3582, "plugin": 3997 } diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 2c486d47721e..71883cc748bc 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -894ae65aebdb803a735b50d7ddd3cfc27793888113afbbb05f417a6442e82db9 config-baseline.json -c2fc50e668ab74128c7d2ada9fd5273e13867afb5642a56a2ba76c174781479d config-baseline.core.json +c6ae555c7162c4ed1472fc5e29f897e0a0029efef2e4d03f2ed19af7a972045d config-baseline.json +c8a10d970dc9f2272207ca399cfff6e11321096c92e5f688fa5be4e5475aa767 config-baseline.core.json 552f5ae69ac13628d754e796bace6e800242d09cacbe762593c17ef3693ba754 config-baseline.channel.json 4bcc2364924c80f38139f0508945b6d28b33b70dd2973f0685a8f221d672ac94 config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline/account-core.json b/docs/.generated/plugin-sdk-api-baseline/account-core.json index 6a424a9e1933..25a765dbfef5 100644 --- a/docs/.generated/plugin-sdk-api-baseline/account-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/account-core.json @@ -1 +1 @@ -{"contentHash":"74ea0a5fceaa6d9219f2d643174784dff0e56abacb7b456bba9237f6890e825b","entrypoint":"account-core","importSpecifier":"openclaw/plugin-sdk/account-core"} +{"contentHash":"341f8faa2d27ffc682259647b34b123a75f794190de8e31e317662bbf81bba4b","entrypoint":"account-core","importSpecifier":"openclaw/plugin-sdk/account-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/account-helpers.json b/docs/.generated/plugin-sdk-api-baseline/account-helpers.json index 74655529becb..2ad086259b3e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/account-helpers.json +++ b/docs/.generated/plugin-sdk-api-baseline/account-helpers.json @@ -1 +1 @@ -{"contentHash":"f23af38bfa07c1a52b003a480b9aff1a6e8ada00c47f7eaf7e474c4aa8d9021b","entrypoint":"account-helpers","importSpecifier":"openclaw/plugin-sdk/account-helpers"} +{"contentHash":"90366ab23e5ff37d52ddcab17f2aae75a5fb4cbd297c60dc71ad2784ce886f6f","entrypoint":"account-helpers","importSpecifier":"openclaw/plugin-sdk/account-helpers"} diff --git a/docs/.generated/plugin-sdk-api-baseline/account-resolution.json b/docs/.generated/plugin-sdk-api-baseline/account-resolution.json index 98fdc16004a2..c25d5c6c8963 100644 --- a/docs/.generated/plugin-sdk-api-baseline/account-resolution.json +++ b/docs/.generated/plugin-sdk-api-baseline/account-resolution.json @@ -1 +1 @@ -{"contentHash":"2f1582d31bcc2a1d9134997e042280185188077210811984c8a39e9a754330fe","entrypoint":"account-resolution","importSpecifier":"openclaw/plugin-sdk/account-resolution"} +{"contentHash":"df2dc27d5a515deba41696812a202d09ae86d06e4c6f09030e747d0e2f3112ec","entrypoint":"account-resolution","importSpecifier":"openclaw/plugin-sdk/account-resolution"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 8e2a460b7f50..4b83dc32c5e9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"eefc72c42de4ca6c5259786aed66e2a209a616679c652d101e08ce250be6643a","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"ada2636485ebedd6593c0223b66d75222498468af082773aad002a69e7253592","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 5e89dd788334..1c953c0370a9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"6f8716be0843d82556326c47da8e9610aa9d4c45f7be35771f8f062025b1954b","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"fc00024b4d58f04ce99f258213f597126bcfc8adae9ad389376c23ae8598ae3a","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-media-payload.json b/docs/.generated/plugin-sdk-api-baseline/agent-media-payload.json index e6ceb8a16c33..f4ffe81a48cc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-media-payload.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-media-payload.json @@ -1 +1 @@ -{"contentHash":"195a863039b6a651c716bf7ea6e6453903fb45a2c09806dcdc1b6605375403ac","entrypoint":"agent-media-payload","importSpecifier":"openclaw/plugin-sdk/agent-media-payload"} +{"contentHash":"6897cf178237b50feefa44bfeb73cee9a1715585019671f216213e4f40bf4b82","entrypoint":"agent-media-payload","importSpecifier":"openclaw/plugin-sdk/agent-media-payload"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json index 7d26079d9ecf..16f5ce5beb10 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json @@ -1 +1 @@ -{"contentHash":"4b54a0f38cb2d46ac2446e6ec7003fddbe1cbe19d8b34d28354b91cb70293f02","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} +{"contentHash":"1d734d04b3acd39d2584c0ce81931fd0d5d00fab706a280d96e0ee01d21fdd4a","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-scope-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-scope-runtime.json index 7cfe4571a1a7..5dda9e857013 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-scope-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-scope-runtime.json @@ -1 +1 @@ -{"contentHash":"7ebe50be2549b7166286b64e28ef320f1de5ca8305a76addbd8871b04d51af77","entrypoint":"agent-scope-runtime","importSpecifier":"openclaw/plugin-sdk/agent-scope-runtime"} +{"contentHash":"973e8db95ce90786af9744d715418bf73ce9a3829d68982ff338f78644c3fd3f","entrypoint":"agent-scope-runtime","importSpecifier":"openclaw/plugin-sdk/agent-scope-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/allowlist-config-edit.json b/docs/.generated/plugin-sdk-api-baseline/allowlist-config-edit.json index 0325ac196125..7c54ebf3f12c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/allowlist-config-edit.json +++ b/docs/.generated/plugin-sdk-api-baseline/allowlist-config-edit.json @@ -1 +1 @@ -{"contentHash":"7567ce81ce9192aaf2d546c6570f42d9e08f4a170f46734ea8ca88c2643060c1","entrypoint":"allowlist-config-edit","importSpecifier":"openclaw/plugin-sdk/allowlist-config-edit"} +{"contentHash":"7e9f6692a46924d0063b60a7b1c728bdee74a842c4ed2230a76a104c63b128bd","entrypoint":"allowlist-config-edit","importSpecifier":"openclaw/plugin-sdk/allowlist-config-edit"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-auth-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-auth-runtime.json index 41d89cfc36c3..d089a8ac7268 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-auth-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-auth-runtime.json @@ -1 +1 @@ -{"contentHash":"91de2702060fb65adc7ff50e78f7209454581fba4dbe285eb1cea20dba45b17a","entrypoint":"approval-auth-runtime","importSpecifier":"openclaw/plugin-sdk/approval-auth-runtime"} +{"contentHash":"bc989599b14494e14bb5a36f5a13596cad1f5e30bdbaff83b99cdfe93d0a3490","entrypoint":"approval-auth-runtime","importSpecifier":"openclaw/plugin-sdk/approval-auth-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-client-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-client-runtime.json index a6f25ed49dbb..53844e4be3f4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-client-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-client-runtime.json @@ -1 +1 @@ -{"contentHash":"4533e6bcac0133c7809df13dc6bee8374441ff0b9b09c4c6dce7752e14871c42","entrypoint":"approval-client-runtime","importSpecifier":"openclaw/plugin-sdk/approval-client-runtime"} +{"contentHash":"e5e17b876f6a943be32fef5c3b5189ce1b1dc39b8e8853ed9adb73b78ed23b8e","entrypoint":"approval-client-runtime","importSpecifier":"openclaw/plugin-sdk/approval-client-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-delivery-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-delivery-runtime.json index bc5a9e3e3fab..b5ad46b820f4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-delivery-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-delivery-runtime.json @@ -1 +1 @@ -{"contentHash":"14417cf83a13c7597febb9ba14211231876b9b449d89ef964fbc86ab81a73da6","entrypoint":"approval-delivery-runtime","importSpecifier":"openclaw/plugin-sdk/approval-delivery-runtime"} +{"contentHash":"13e5797a127d9900e804963e7b0ab4966e17e96bcd5a6e772aaa3a6a3a6c223d","entrypoint":"approval-delivery-runtime","importSpecifier":"openclaw/plugin-sdk/approval-delivery-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-gateway-runtime.json index 162bb59f4bf5..cbe9684ea53d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"932de389fe2df73f703c5b77d79c36845e79cb2915d3b66b7756d479c434f24c","entrypoint":"approval-gateway-runtime","importSpecifier":"openclaw/plugin-sdk/approval-gateway-runtime"} +{"contentHash":"cc1ad6dbd5258e2c2b40f345866ab3c19cec6d1a779e9e36f007bb3639ff52d5","entrypoint":"approval-gateway-runtime","importSpecifier":"openclaw/plugin-sdk/approval-gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-handler-adapter-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-handler-adapter-runtime.json index e5f0b4c4fb2b..034493cfe57f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-handler-adapter-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-handler-adapter-runtime.json @@ -1 +1 @@ -{"contentHash":"86ea00b5f1f272b84c63ae497b5abfadebdfa51d009eb09ace4e8499696fd4ba","entrypoint":"approval-handler-adapter-runtime","importSpecifier":"openclaw/plugin-sdk/approval-handler-adapter-runtime"} +{"contentHash":"1a765a2b51751cdac6f57ad06be857445bce69cbe0f94da09f0d4e54ab7b665e","entrypoint":"approval-handler-adapter-runtime","importSpecifier":"openclaw/plugin-sdk/approval-handler-adapter-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-handler-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-handler-runtime.json index 3e424a4ccf7f..b6f43a9ef345 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-handler-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-handler-runtime.json @@ -1 +1 @@ -{"contentHash":"80835d0bb17661d85c9c3315724e37ba3801d69c7f58fc555c15466f35ef651f","entrypoint":"approval-handler-runtime","importSpecifier":"openclaw/plugin-sdk/approval-handler-runtime"} +{"contentHash":"eaaf9e2c1ea291d0111788ebf04f0ec806b25a77e09a367ff93e7a808102607c","entrypoint":"approval-handler-runtime","importSpecifier":"openclaw/plugin-sdk/approval-handler-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-native-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-native-runtime.json index c872ad455552..5b2d37715603 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-native-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-native-runtime.json @@ -1 +1 @@ -{"contentHash":"be74733e90c57f98afd388799824e68532b3d8a5fc46f51abbc0ae6b1a2acfe6","entrypoint":"approval-native-runtime","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime"} +{"contentHash":"7682bb32e47cb9a4feda91f218507358799d09f5886c27efdc4319cd133bb057","entrypoint":"approval-native-runtime","importSpecifier":"openclaw/plugin-sdk/approval-native-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/approval-runtime.json b/docs/.generated/plugin-sdk-api-baseline/approval-runtime.json index 01cdeacd767e..b072ea62cfa9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/approval-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/approval-runtime.json @@ -1 +1 @@ -{"contentHash":"bc96dace6be0e69bf7bd8ec89efebacbd507be2c9b5a47844183f6bb3374c56f","entrypoint":"approval-runtime","importSpecifier":"openclaw/plugin-sdk/approval-runtime"} +{"contentHash":"45770b7d266ec06025bdd370beaced6261a950e68744a29aa7ac6569076b4b49","entrypoint":"approval-runtime","importSpecifier":"openclaw/plugin-sdk/approval-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-config-helpers.json b/docs/.generated/plugin-sdk-api-baseline/channel-config-helpers.json index 7d5f5c505898..e471b1705430 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-config-helpers.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-config-helpers.json @@ -1 +1 @@ -{"contentHash":"f74ea9a2fb50bf48fa29955825135ac63ad387099150cc784b0f5e9cb93fda3c","entrypoint":"channel-config-helpers","importSpecifier":"openclaw/plugin-sdk/channel-config-helpers"} +{"contentHash":"a8010cc04c53d88f4ce795af44c5cc0609029e2a5409ca70cd83237c27ade00a","entrypoint":"channel-config-helpers","importSpecifier":"openclaw/plugin-sdk/channel-config-helpers"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-contract.json index 35607427e561..6dacbbbb6bb8 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-contract.json @@ -1 +1 @@ -{"contentHash":"30b463a4e09c326f52255ad4015c8546c7bca5475d33d784969e2cc4f1feed40","entrypoint":"channel-contract","importSpecifier":"openclaw/plugin-sdk/channel-contract"} +{"contentHash":"44832134039fb5a9b5c3d001ee0daf500a55ef44eedbf31e19756735e7bf8bff","entrypoint":"channel-contract","importSpecifier":"openclaw/plugin-sdk/channel-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index a38de2b73e8d..37df2f9b590f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"895ac29e8056362777291f51a27d021813de8f3a4acce30e57b1b82843cd27bc","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"ae7ebc2ff1de2a97232b992dd6cf494bc80f4666fa6f92bf05411c4412d0e089","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-dm-policy.json b/docs/.generated/plugin-sdk-api-baseline/channel-dm-policy.json index 28a956f0d9a5..a5af64a1a80d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-dm-policy.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-dm-policy.json @@ -1 +1 @@ -{"contentHash":"f25d352d0cdce67f2455b006129d6661d8d254d31774ffc6923e9d4c92eb9250","entrypoint":"channel-dm-policy","importSpecifier":"openclaw/plugin-sdk/channel-dm-policy"} +{"contentHash":"709a490a231838b6b65fdce47fe05fd3aad609398f573d6b45bfcceec5852a66","entrypoint":"channel-dm-policy","importSpecifier":"openclaw/plugin-sdk/channel-dm-policy"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 838300b9bde5..5d8e7538a3ec 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"a109615d9c3222bdbf04be69e222e4af4e314820c72b688c909fec6d0487bdc6","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"15e8d88af3ded7a56f56d3eca4f59dbcc14d2d14dd4f5d291919eb204528082d","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-feedback.json b/docs/.generated/plugin-sdk-api-baseline/channel-feedback.json index e15ebc9ca433..69e40b3e12c9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-feedback.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-feedback.json @@ -1 +1 @@ -{"contentHash":"551b4cd1d6940447dd445cc28f07544ad6f5974d65e241a4816f8d03a69b8f82","entrypoint":"channel-feedback","importSpecifier":"openclaw/plugin-sdk/channel-feedback"} +{"contentHash":"e60e497ec83a74c69afdad695939551cc65b13527f0b38bfac88f2bf5fbe6ce2","entrypoint":"channel-feedback","importSpecifier":"openclaw/plugin-sdk/channel-feedback"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-inbound-debounce.json b/docs/.generated/plugin-sdk-api-baseline/channel-inbound-debounce.json index 3ad54b9eb962..cebd94bf983b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-inbound-debounce.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-inbound-debounce.json @@ -1 +1 @@ -{"contentHash":"e77cdd92e4f38cdd2a700cfc542402be3aae560c6c0c5c309c39286bdbb61abe","entrypoint":"channel-inbound-debounce","importSpecifier":"openclaw/plugin-sdk/channel-inbound-debounce"} +{"contentHash":"6aa8947155130bdf1eeaf2387d4fe855d78702f3bd82cc518d907244e8f7f089","entrypoint":"channel-inbound-debounce","importSpecifier":"openclaw/plugin-sdk/channel-inbound-debounce"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json index c3cab6171e07..b576232af543 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json @@ -1 +1 @@ -{"contentHash":"84ecd36a64b3aec85589103ef5e3e332ea0770651c0cc344090c6a3c609b6854","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} +{"contentHash":"6add8ecd718fa670825ac3420b85e3acbb17fdf86e21d14f308f2f2657f1e5be","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-ingress-runtime.json b/docs/.generated/plugin-sdk-api-baseline/channel-ingress-runtime.json index c22007a03688..38261d7795e3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-ingress-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-ingress-runtime.json @@ -1 +1 @@ -{"contentHash":"d98f19fb7df1291cfe33dfe01b99485108dbeff47768eedf3b51a3fd4bce76a0","entrypoint":"channel-ingress-runtime","importSpecifier":"openclaw/plugin-sdk/channel-ingress-runtime"} +{"contentHash":"c0541f2781168816d232c6690ed8896f422ce2b7ce950f117328cc2eeda99d0e","entrypoint":"channel-ingress-runtime","importSpecifier":"openclaw/plugin-sdk/channel-ingress-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 1c66f9b4b8c1..193a54269aa6 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"c85d86fb7e98c9606a9ea4f44e99d97cf4e60708288b539c635ba2f6fc7d0f1a","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"5ae94502f0098ffd3c91160440795c3955bc63558e034688ab890f0edac27e74","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index 2dfd78f39d1b..33e4bff31724 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"abfb0bc418ade50fbceec696dbc19198d10e5596965e264d1202924f0f7d5761","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"9b17e70764b958845e9b5ad547b8a58c730acd76c450137b7063de4d84724d91","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json b/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json index cf53792a6a90..5c7f2fb45a17 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json @@ -1 +1 @@ -{"contentHash":"982f42cd1ef5594aff26969ac256e5cf2f124f409b7e020fcad5117fc8322055","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"} +{"contentHash":"83162d60aa40acbfd9752b887848447842690d21e8432abcbe4dfd0b8d346567","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 876e27b4f72e..6a26379f8ac1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"3a9f81af4a5140ef51cc73bf117138dd318dd89cb743e40eba2970792b1d9980","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"628a6a5b9674566c5956e04e505be93079c8324bd2701368c6c47adbe93956cd","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-policy.json b/docs/.generated/plugin-sdk-api-baseline/channel-policy.json index 379ad83c5b62..1ea347d1ea79 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-policy.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-policy.json @@ -1 +1 @@ -{"contentHash":"2dfa0507e128854c5df2f833e57e56c4cabdf6e32c4d79f26fe21b674b240f69","entrypoint":"channel-policy","importSpecifier":"openclaw/plugin-sdk/channel-policy"} +{"contentHash":"32933d2e3143e7e5db2a0b032187f3eef2353d7f25ad64d079bb572c5ba40aae","entrypoint":"channel-policy","importSpecifier":"openclaw/plugin-sdk/channel-policy"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json b/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json index 456a8961afef..347c00d54d0f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json @@ -1 +1 @@ -{"contentHash":"1262cc14d9cb4c639ead54a2c2e4c1042cb836b73bdd9d3a39a9664ae04241e9","entrypoint":"channel-reply-pipeline","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline"} +{"contentHash":"3b5e25a136fd2cd150a20f35c7c92e4a36f2384e980d8b8f3c5597da0ad76f70","entrypoint":"channel-reply-pipeline","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json b/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json index aa926456183c..6523d7ddd110 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json @@ -1 +1 @@ -{"contentHash":"bc8c23c5a5108c1781648509f7b4c6c07ad4e4a064f726d517ece59d8907f19c","entrypoint":"channel-secret-basic-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-basic-runtime"} +{"contentHash":"e2c744c0afe545e4cd57b5aaba6db9133187bde3bf462c063c000773846d5803","entrypoint":"channel-secret-basic-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-basic-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-secret-runtime.json b/docs/.generated/plugin-sdk-api-baseline/channel-secret-runtime.json index 6ab4c9893bdd..29890ace6b91 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-secret-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-secret-runtime.json @@ -1 +1 @@ -{"contentHash":"812007b404b41c995529acbc1fadc9fd5661a6a87676bb5dce2f30e52327becf","entrypoint":"channel-secret-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-runtime"} +{"contentHash":"6d80a38ab05277753106ded06e3299b53e0679c695aaf4178f393cd55a6c5dda","entrypoint":"channel-secret-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-send-result.json b/docs/.generated/plugin-sdk-api-baseline/channel-send-result.json index 84a1a434426b..d6d17f262229 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-send-result.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-send-result.json @@ -1 +1 @@ -{"contentHash":"24e36948ee19def3102474d3e3ba0964515db1ef0e06eafdc66ebc733828bb13","entrypoint":"channel-send-result","importSpecifier":"openclaw/plugin-sdk/channel-send-result"} +{"contentHash":"b4d94ff4e37aa07c2944bce09a7b644cbd3844ee2aa3b1ddf9b378848d27e250","entrypoint":"channel-send-result","importSpecifier":"openclaw/plugin-sdk/channel-send-result"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-setup.json b/docs/.generated/plugin-sdk-api-baseline/channel-setup.json index c0075b14a8e8..5794c9224187 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-setup.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-setup.json @@ -1 +1 @@ -{"contentHash":"e981991086dc04c69b385d4860acffcb332560368283000ed42b1e4db5a43896","entrypoint":"channel-setup","importSpecifier":"openclaw/plugin-sdk/channel-setup"} +{"contentHash":"ca58d4cec17d38db2573603e47743ec9ad1cf64b35b6f7453a36f4e2cc16f02e","entrypoint":"channel-setup","importSpecifier":"openclaw/plugin-sdk/channel-setup"} diff --git a/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json b/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json index 3197d444a86a..685bea4e731f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json +++ b/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json @@ -1 +1 @@ -{"contentHash":"4fa7e5cf0b6aadbaef2a7ad6dcf0cd732f0284a061e8006e6e1353daec6a4224","entrypoint":"command-auth-native","importSpecifier":"openclaw/plugin-sdk/command-auth-native"} +{"contentHash":"ae33abaed302c429a458f4062e99151e5f9e3c86d345278633555eef00fbb6fd","entrypoint":"command-auth-native","importSpecifier":"openclaw/plugin-sdk/command-auth-native"} diff --git a/docs/.generated/plugin-sdk-api-baseline/command-auth.json b/docs/.generated/plugin-sdk-api-baseline/command-auth.json index 09af1c1af2b4..c02b5c800dde 100644 --- a/docs/.generated/plugin-sdk-api-baseline/command-auth.json +++ b/docs/.generated/plugin-sdk-api-baseline/command-auth.json @@ -1 +1 @@ -{"contentHash":"e08dc22849e8bca609d45e73a004b3293e8610d105040b93118ab125978d8516","entrypoint":"command-auth","importSpecifier":"openclaw/plugin-sdk/command-auth"} +{"contentHash":"5872a8883dcac2ecdaed449a23629b0ae975e9c869f18469ec950073fbdd03ee","entrypoint":"command-auth","importSpecifier":"openclaw/plugin-sdk/command-auth"} diff --git a/docs/.generated/plugin-sdk-api-baseline/command-detection.json b/docs/.generated/plugin-sdk-api-baseline/command-detection.json index d7b83fc1ebf2..ceede193d89d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/command-detection.json +++ b/docs/.generated/plugin-sdk-api-baseline/command-detection.json @@ -1 +1 @@ -{"contentHash":"037ea2f2234b727590643ca08f315041315bce974b0c4c292470abc6f2d3d1fb","entrypoint":"command-detection","importSpecifier":"openclaw/plugin-sdk/command-detection"} +{"contentHash":"c7cbbcf71875c9b6b2769ae477dd62f2b43773f6f26ed947bd6aa5fdcdffd862","entrypoint":"command-detection","importSpecifier":"openclaw/plugin-sdk/command-detection"} diff --git a/docs/.generated/plugin-sdk-api-baseline/command-status.json b/docs/.generated/plugin-sdk-api-baseline/command-status.json index 16da9a3a093e..10a18c2e4e5a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/command-status.json +++ b/docs/.generated/plugin-sdk-api-baseline/command-status.json @@ -1 +1 @@ -{"contentHash":"5ad0b6ccf41bee6a0c14d18886815fcfdae46c74f4fe6d4a1b694236f4c8cf9d","entrypoint":"command-status","importSpecifier":"openclaw/plugin-sdk/command-status"} +{"contentHash":"fb366f38f40b284abb21fa043c5a56e56286f658f104647c2143eac03b5a0952","entrypoint":"command-status","importSpecifier":"openclaw/plugin-sdk/command-status"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-contracts.json b/docs/.generated/plugin-sdk-api-baseline/config-contracts.json index f4fc18b70bfc..980ae6b9b393 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-contracts.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-contracts.json @@ -1 +1 @@ -{"contentHash":"276229acce4fc27e7eccf3e970b26c05a2f42ba37e82e969c139f997ff681427","entrypoint":"config-contracts","importSpecifier":"openclaw/plugin-sdk/config-contracts"} +{"contentHash":"ec4f7122f12e97f6163cd38d6301d5e07f13aabd20a58e4cc03a9f009d4becbb","entrypoint":"config-contracts","importSpecifier":"openclaw/plugin-sdk/config-contracts"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-mutation.json b/docs/.generated/plugin-sdk-api-baseline/config-mutation.json index f7ad9d6ff24a..1c8ab4c2c912 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-mutation.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-mutation.json @@ -1 +1 @@ -{"contentHash":"a7c63538d37122bfe240f75944916a3f01806f7fb7de3ecb976c6fd3f5dcd72e","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"} +{"contentHash":"ba38d34dfd1a7a1bf001f151489dcdb8f17a37444843028d5cc2658c59430169","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-runtime.json b/docs/.generated/plugin-sdk-api-baseline/config-runtime.json index 6cc903f9d13d..f7dc671f5115 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-runtime.json @@ -1 +1 @@ -{"contentHash":"3f128cc41bb2e44b40402774acd3a967501c72ae5d7b2f9d149a262cc0d5a2da","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"} +{"contentHash":"0d168836f18045e7e5e80495efaf3b1774691723884898802a214a3e7c1bfd6a","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/conversation-runtime.json b/docs/.generated/plugin-sdk-api-baseline/conversation-runtime.json index b1165608271b..dee48af96c50 100644 --- a/docs/.generated/plugin-sdk-api-baseline/conversation-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/conversation-runtime.json @@ -1 +1 @@ -{"contentHash":"b0b4951cd020c358a1d27888c7794a6021c9e714502fa73becafaae100e67003","entrypoint":"conversation-runtime","importSpecifier":"openclaw/plugin-sdk/conversation-runtime"} +{"contentHash":"7a94d6151701bd7f3607f4f0b74118c9429f21527446bbedec90cfc46ca79172","entrypoint":"conversation-runtime","importSpecifier":"openclaw/plugin-sdk/conversation-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index c278118d6d53..bf5a410003ee 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"f808b2dea87ef2f60c3d3867aabcade645ce918b4c273d1dcdb452e4b110aa0f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"a0a3832ec0e4a2529333b18d4474940e773b9afe01e8cd13c1d79edf75f583f4","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/diagnostic-runtime.json b/docs/.generated/plugin-sdk-api-baseline/diagnostic-runtime.json index c321ad2a664d..547039cd5875 100644 --- a/docs/.generated/plugin-sdk-api-baseline/diagnostic-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/diagnostic-runtime.json @@ -1 +1 @@ -{"contentHash":"85384810d4e097269845c3d9ac8a11938b37acb4f231a09fa6224265ed76d8a4","entrypoint":"diagnostic-runtime","importSpecifier":"openclaw/plugin-sdk/diagnostic-runtime"} +{"contentHash":"547d04b5b0092daca9b22e80e9cf358c87d18cf4e85420049c21f56ae25a6c03","entrypoint":"diagnostic-runtime","importSpecifier":"openclaw/plugin-sdk/diagnostic-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/directory-runtime.json b/docs/.generated/plugin-sdk-api-baseline/directory-runtime.json index a2118d092757..33d1d7daac31 100644 --- a/docs/.generated/plugin-sdk-api-baseline/directory-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/directory-runtime.json @@ -1 +1 @@ -{"contentHash":"a8330c979e92d4d9d4f1f050c7ce7e7d0198e8a25b2caf3706b8596cfc600129","entrypoint":"directory-runtime","importSpecifier":"openclaw/plugin-sdk/directory-runtime"} +{"contentHash":"5559c9312d56df1cef6a1bb56e7bad1ae13efaee0bb3f1d9fbb7e563e3172f11","entrypoint":"directory-runtime","importSpecifier":"openclaw/plugin-sdk/directory-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index b4cdcf12a908..5b9920a98724 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"29d5e02c1f225835cca47a07f807f317d77782e05bdce88b0d4a58d04d85b81b","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"2365d560ccc2c217c971c57918a096b2921727cc1df4357c1af486251c126048","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/extension-shared.json b/docs/.generated/plugin-sdk-api-baseline/extension-shared.json index b03d22df2d98..021a3e9e9148 100644 --- a/docs/.generated/plugin-sdk-api-baseline/extension-shared.json +++ b/docs/.generated/plugin-sdk-api-baseline/extension-shared.json @@ -1 +1 @@ -{"contentHash":"db169149223eeb4f3d4dbbcf5d8c4111db6dfbddbaa4e7fb4b27f55d35723faa","entrypoint":"extension-shared","importSpecifier":"openclaw/plugin-sdk/extension-shared"} +{"contentHash":"5f55bbb4c9f30b1694f940472eb8b4deb9707a70c0aabb0017b58da7d8440f56","entrypoint":"extension-shared","importSpecifier":"openclaw/plugin-sdk/extension-shared"} diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index 9ce176ac1bcc..b460aee89486 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"8cf1f6a307496e0c44bc8c413d710bf069681fa506d5af3508f7ba437065c6e2","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"7c27ccd856e944332b06e5c79ee0275bad1c3e2d1a2174f108d5ead742b32783","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/health.json b/docs/.generated/plugin-sdk-api-baseline/health.json index 29792bf5fda6..1ae87017c606 100644 --- a/docs/.generated/plugin-sdk-api-baseline/health.json +++ b/docs/.generated/plugin-sdk-api-baseline/health.json @@ -1 +1 @@ -{"contentHash":"804f34575cadcf502248f68a2d0539da6a13bd8a4cfcbac7eedf377eb5e100bc","entrypoint":"health","importSpecifier":"openclaw/plugin-sdk/health"} +{"contentHash":"a94e3c690d1c684c51e901469209fa20b8fca6a7b0c10b3e4deac98d9a7d8be7","entrypoint":"health","importSpecifier":"openclaw/plugin-sdk/health"} diff --git a/docs/.generated/plugin-sdk-api-baseline/hook-runtime.json b/docs/.generated/plugin-sdk-api-baseline/hook-runtime.json index c1b7f3460d09..9d5423ff5a25 100644 --- a/docs/.generated/plugin-sdk-api-baseline/hook-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/hook-runtime.json @@ -1 +1 @@ -{"contentHash":"372c9a2f49fd5d431197b7f35c37321a4d4296d7a1cddc2b71c3ee757ebe95a7","entrypoint":"hook-runtime","importSpecifier":"openclaw/plugin-sdk/hook-runtime"} +{"contentHash":"8b94bf5fad2d42b30662181f258d5486f875302cb46fc15fd37727458282a931","entrypoint":"hook-runtime","importSpecifier":"openclaw/plugin-sdk/hook-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 5387a8558f87..563f328aaf79 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"0212bcd1e5e4740ed61cf0773e13ee51d777b64a5fd51c8fa3060398e5f129b2","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"0b9df99990acff17f2a8980c944ec6dbd6e6a1797d82238b0684e61cae7c227b","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/infra-runtime.json b/docs/.generated/plugin-sdk-api-baseline/infra-runtime.json index 6190e3aba025..12fc38cf8fcc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/infra-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/infra-runtime.json @@ -1 +1 @@ -{"contentHash":"463ab9088e5cbf0f008716d00676aaf2a4bdf0f13a6621cd42a76aac34f5b7a0","entrypoint":"infra-runtime","importSpecifier":"openclaw/plugin-sdk/infra-runtime"} +{"contentHash":"8fb1a350a9618826569ddd6482e25b670756748db6e6c788dfb4466d9c6d61a8","entrypoint":"infra-runtime","importSpecifier":"openclaw/plugin-sdk/infra-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/logging-core.json b/docs/.generated/plugin-sdk-api-baseline/logging-core.json index eee7580af4d5..8655ae0d411f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/logging-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/logging-core.json @@ -1 +1 @@ -{"contentHash":"b8cc5f216a28c2606a2c8439fb5476a8e229459950e9e406c5ac46174c9eee2e","entrypoint":"logging-core","importSpecifier":"openclaw/plugin-sdk/logging-core"} +{"contentHash":"27b122cd12cb3be2b5c9070fdf86a9dddc462d7cc54a0a7d78f96d01e0863d1b","entrypoint":"logging-core","importSpecifier":"openclaw/plugin-sdk/logging-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/media-local-roots.json b/docs/.generated/plugin-sdk-api-baseline/media-local-roots.json index 0b04748cded4..3a84dd60acc1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/media-local-roots.json +++ b/docs/.generated/plugin-sdk-api-baseline/media-local-roots.json @@ -1 +1 @@ -{"contentHash":"a1c9573dc582ab69ee317dea444b9eae78da555a44186b66c427fd12e11d7ac9","entrypoint":"media-local-roots","importSpecifier":"openclaw/plugin-sdk/media-local-roots"} +{"contentHash":"cad600d7347448c638bbabb34d8197a6704fbd8971bf73e21074fb3c40e99a71","entrypoint":"media-local-roots","importSpecifier":"openclaw/plugin-sdk/media-local-roots"} diff --git a/docs/.generated/plugin-sdk-api-baseline/media-runtime.json b/docs/.generated/plugin-sdk-api-baseline/media-runtime.json index 3a43ce46cb20..a19b6524c36f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/media-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/media-runtime.json @@ -1 +1 @@ -{"contentHash":"fe35d8eebcbfbdb20fc4a1e3b903e2dc7891dc2f12feb3e6130fec5dcd605376","entrypoint":"media-runtime","importSpecifier":"openclaw/plugin-sdk/media-runtime"} +{"contentHash":"b1c3105c62e6e156581803ef34e4238d6f230955f78890f3deb78f6c99d97f0c","entrypoint":"media-runtime","importSpecifier":"openclaw/plugin-sdk/media-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/media-understanding-runtime.json b/docs/.generated/plugin-sdk-api-baseline/media-understanding-runtime.json index 587938882db8..ddd8ad9dea31 100644 --- a/docs/.generated/plugin-sdk-api-baseline/media-understanding-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/media-understanding-runtime.json @@ -1 +1 @@ -{"contentHash":"7d5fd82c531675b86446df475d1744902b721dbcc345aeabaf7bdfee27a77bc0","entrypoint":"media-understanding-runtime","importSpecifier":"openclaw/plugin-sdk/media-understanding-runtime"} +{"contentHash":"30969ae3c8b79c39336765b983fbecaeb108ee1da09c0f489d096d9f993231bb","entrypoint":"media-understanding-runtime","importSpecifier":"openclaw/plugin-sdk/media-understanding-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/media-understanding.json b/docs/.generated/plugin-sdk-api-baseline/media-understanding.json index 841ba9213e68..e1d3b8d3b16a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/media-understanding.json +++ b/docs/.generated/plugin-sdk-api-baseline/media-understanding.json @@ -1 +1 @@ -{"contentHash":"92c946421c6f907442685fd0cfb39b42c5a25fda2a44931d7c0fbbaf12245bd9","entrypoint":"media-understanding","importSpecifier":"openclaw/plugin-sdk/media-understanding"} +{"contentHash":"a92d512914c10662f2a47d27ab7173fd3aefa6442633265e3b172d53b5c28ff1","entrypoint":"media-understanding","importSpecifier":"openclaw/plugin-sdk/media-understanding"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index ae96425e3ebe..44d8d2722ed3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"6c15ce9bac50287422269210a848b33a6cca4684b8f7801be93fbe2f04e0d45e","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"558d33dffe62849474af6ba9706c57b2b26ebf71187ee04f4dcfae039caa0678","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/memory-core-host-engine-foundation.json b/docs/.generated/plugin-sdk-api-baseline/memory-core-host-engine-foundation.json index 35fe3e2ee613..a67e2fb8230c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/memory-core-host-engine-foundation.json +++ b/docs/.generated/plugin-sdk-api-baseline/memory-core-host-engine-foundation.json @@ -1 +1 @@ -{"contentHash":"c502491a40bd1a579d314e4673c3a3cdba15c6dada8ace82369d1f31b393b9ee","entrypoint":"memory-core-host-engine-foundation","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation"} +{"contentHash":"e9a1fee2e3a1f66f5a6cc69648092de5fbf4dea051f3ecdd732519c1d95d3ae5","entrypoint":"memory-core-host-engine-foundation","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation"} diff --git a/docs/.generated/plugin-sdk-api-baseline/memory-host-core.json b/docs/.generated/plugin-sdk-api-baseline/memory-host-core.json index 57f65a014293..25a701f394b9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/memory-host-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/memory-host-core.json @@ -1 +1 @@ -{"contentHash":"f771af96c027a82204bb41263fbd702424108bca8eb1670bd85fde4f8c3a0a8c","entrypoint":"memory-host-core","importSpecifier":"openclaw/plugin-sdk/memory-host-core"} +{"contentHash":"44d49b85e4b04590c2a8739ee09a4dbc2093d42a37c2252b6c8953bc6c560587","entrypoint":"memory-host-core","importSpecifier":"openclaw/plugin-sdk/memory-host-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json b/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json index e1e954a02924..1f7ffb671dd2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json @@ -1 +1 @@ -{"contentHash":"ecb40006fc5ea974a3e31d0782c86e50c054e19164f864a564bb6f3f3d00dae9","entrypoint":"model-session-runtime","importSpecifier":"openclaw/plugin-sdk/model-session-runtime"} +{"contentHash":"486acdea7964518d809c83435cd6ce817ff5afc710e72385f2edde3adfa46478","entrypoint":"model-session-runtime","importSpecifier":"openclaw/plugin-sdk/model-session-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json b/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json index 35a47c530eb3..0378f0b9b5e2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json @@ -1 +1 @@ -{"contentHash":"cdc1b761783a3e135f413f08c0f107a7dd45f2f73d9f4b807244590445ff5343","entrypoint":"models-provider-runtime","importSpecifier":"openclaw/plugin-sdk/models-provider-runtime"} +{"contentHash":"ff0868c94c8fa03e3323f5a6e9ceb886c577f2b07bcc3e7c377b378440d46179","entrypoint":"models-provider-runtime","importSpecifier":"openclaw/plugin-sdk/models-provider-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/native-command-config-runtime.json b/docs/.generated/plugin-sdk-api-baseline/native-command-config-runtime.json index 05204dd3a77d..584cef041917 100644 --- a/docs/.generated/plugin-sdk-api-baseline/native-command-config-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/native-command-config-runtime.json @@ -1 +1 @@ -{"contentHash":"c3ef6bb24b2e3533b60de73f9196c0cedeb82043586afb5381f0ae97a12ca75f","entrypoint":"native-command-config-runtime","importSpecifier":"openclaw/plugin-sdk/native-command-config-runtime"} +{"contentHash":"437aa32685322f36a938b669db4b1272e12300dbdf9fa5cfb988a382b86a1d10","entrypoint":"native-command-config-runtime","importSpecifier":"openclaw/plugin-sdk/native-command-config-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/native-command-registry.json b/docs/.generated/plugin-sdk-api-baseline/native-command-registry.json index 717864c519a4..e9eb9733de74 100644 --- a/docs/.generated/plugin-sdk-api-baseline/native-command-registry.json +++ b/docs/.generated/plugin-sdk-api-baseline/native-command-registry.json @@ -1 +1 @@ -{"contentHash":"1a7c4d246a5d9bb91defd483dc2af094199898cc89be05366fd2c7855aa7cc15","entrypoint":"native-command-registry","importSpecifier":"openclaw/plugin-sdk/native-command-registry"} +{"contentHash":"e1c4ad0de14b7dd5bacd210bd6116890e7b14aa0b26ceebc531a8471e7aaf307","entrypoint":"native-command-registry","importSpecifier":"openclaw/plugin-sdk/native-command-registry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json index fb7cb4f1ce9d..18f1fbe31f6e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json @@ -1 +1 @@ -{"contentHash":"cbbbef2c74bfbd0dc0836cf89c5010f3c0e2e0def3663b076d57a7a6bc8a13cd","entrypoint":"plugin-command-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-command-runtime"} +{"contentHash":"25ec4d9bc9a8079e69f085cbe886fbeda3400bc951b9aaf0f79ff945c6a97958","entrypoint":"plugin-command-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-command-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-config-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-config-runtime.json index 6a4445a27368..46de4a34f100 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-config-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-config-runtime.json @@ -1 +1 @@ -{"contentHash":"b5a2ad221927505a92ff1e4520e14b916ff2ec2a771e7173d0279b85a93bb5a7","entrypoint":"plugin-config-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-config-runtime"} +{"contentHash":"5dc9002a8df3cf9477ab2fa96fcd0c0eb44a0565dff033491c2bdbe8d1eb662b","entrypoint":"plugin-config-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-config-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index c74d52df5245..ecf323a28a59 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"66b685d9302b1e6bb5e348c0b164944e7f92087d480ca36ced0855316f6d9385","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"27b07f72e16f1a0d0bbb143d3c19ee80064914971909bc72099051f68ceb7b89","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index d51603a969da..3951008faf3b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"2120a39bb107f406046340baa16e316e47341451d21f76d28f666d6b27fc4673","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"71cb620f8b936e6d83b260f45e9ebb16e55363b6fd7bccd8944632e78b14d70d","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-auth.json b/docs/.generated/plugin-sdk-api-baseline/provider-auth.json index 11124ace8d6d..3ebef309bc65 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-auth.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-auth.json @@ -1 +1 @@ -{"contentHash":"d56a974704a97ae438b03ba82aa84314e77bf65a7e7dd9096fd2ab2bc8cbcbec","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"} +{"contentHash":"8c89c076b4fa9f2017e136c4a1b5f55863c469039592b00e1e40b777e7621533","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 7821ff91dfce..e3b059c40076 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"f127c5c3738201fb8972ab46c145a9001889c211fcde48c395c06818ea627bf8","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"a5c6beede9403833929c5491019c92d691df2f092c1db13c9caf125da4013aaa","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/question-gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/question-gateway-runtime.json index 95a96954174b..b480ac618e19 100644 --- a/docs/.generated/plugin-sdk-api-baseline/question-gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/question-gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"f379950391cc668ede32800d0dab5dc0a37c1589b4d57abb0790913a15e9aadb","entrypoint":"question-gateway-runtime","importSpecifier":"openclaw/plugin-sdk/question-gateway-runtime"} +{"contentHash":"88602945e3b0d15894673e46412535f1e144efb1096f7061f71ed92f2978a2a8","entrypoint":"question-gateway-runtime","importSpecifier":"openclaw/plugin-sdk/question-gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-chunking.json b/docs/.generated/plugin-sdk-api-baseline/reply-chunking.json index 0917d50c75ee..f24f7a2fe6be 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-chunking.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-chunking.json @@ -1 +1 @@ -{"contentHash":"9023a0d9a45ae97efb7c59cc90c5afd9cf5acd0644722332a99aa6609c14a7b6","entrypoint":"reply-chunking","importSpecifier":"openclaw/plugin-sdk/reply-chunking"} +{"contentHash":"b74f9fb9eca3c2a702c5c55c8efb1ef24ec945200c1a9de4d620a72c7b3f3edf","entrypoint":"reply-chunking","importSpecifier":"openclaw/plugin-sdk/reply-chunking"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json b/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json index 577914c7b783..589d7431b271 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json @@ -1 +1 @@ -{"contentHash":"a41c05428a9a5430b2d81ed310aa5efcbeddcef7573adfd8af5ec415bfbbba97","entrypoint":"reply-dispatch-runtime","importSpecifier":"openclaw/plugin-sdk/reply-dispatch-runtime"} +{"contentHash":"cc49e239b9fd3e4bf1d3fe0ede441030df46a79dc0f707313f2367453ebbe10c","entrypoint":"reply-dispatch-runtime","importSpecifier":"openclaw/plugin-sdk/reply-dispatch-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-payload.json b/docs/.generated/plugin-sdk-api-baseline/reply-payload.json index a70ba6ffcadd..f7ac9a5d1311 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-payload.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-payload.json @@ -1 +1 @@ -{"contentHash":"f421205d77076c2f45e9c15c2f5bab37b3a91a9e21c1d8f945f53e1b0e936e00","entrypoint":"reply-payload","importSpecifier":"openclaw/plugin-sdk/reply-payload"} +{"contentHash":"4debec483404d8abe5b9ae46166a159218a150b4e9af1a52d7b5f2feb6feca04","entrypoint":"reply-payload","importSpecifier":"openclaw/plugin-sdk/reply-payload"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json b/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json index 27b48726206c..6461a1f6bcad 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json @@ -1 +1 @@ -{"contentHash":"74f042e649fe9ad18cee1eb327622b526636b470e7c230c2e280e11e990c7764","entrypoint":"reply-runtime","importSpecifier":"openclaw/plugin-sdk/reply-runtime"} +{"contentHash":"e0ab7706edb6c7f0c021980ec0068348456db88917c6b113a1010d7a4519fef1","entrypoint":"reply-runtime","importSpecifier":"openclaw/plugin-sdk/reply-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/routing.json b/docs/.generated/plugin-sdk-api-baseline/routing.json index f4d19e429080..10dd4559f243 100644 --- a/docs/.generated/plugin-sdk-api-baseline/routing.json +++ b/docs/.generated/plugin-sdk-api-baseline/routing.json @@ -1 +1 @@ -{"contentHash":"dba046330db0bc493ddedbedaa42ef6fcf599fccb472c1f8a8722d3431d15382","entrypoint":"routing","importSpecifier":"openclaw/plugin-sdk/routing"} +{"contentHash":"2d388c0e84a58fbdc3dbb1171348b795047fa0db2b003530d7107253a7ff4389","entrypoint":"routing","importSpecifier":"openclaw/plugin-sdk/routing"} diff --git a/docs/.generated/plugin-sdk-api-baseline/runtime-config-snapshot.json b/docs/.generated/plugin-sdk-api-baseline/runtime-config-snapshot.json index a6da2ec83be2..b71ea8979300 100644 --- a/docs/.generated/plugin-sdk-api-baseline/runtime-config-snapshot.json +++ b/docs/.generated/plugin-sdk-api-baseline/runtime-config-snapshot.json @@ -1 +1 @@ -{"contentHash":"efa884588bd728ec21bebf07d2700f1f477f7c4adeb0646190f0b6146d9d167a","entrypoint":"runtime-config-snapshot","importSpecifier":"openclaw/plugin-sdk/runtime-config-snapshot"} +{"contentHash":"66d78144cb06fcec237e851afd5ef2bf38fad8c75d1984c81a21f39f8ea9bc8b","entrypoint":"runtime-config-snapshot","importSpecifier":"openclaw/plugin-sdk/runtime-config-snapshot"} diff --git a/docs/.generated/plugin-sdk-api-baseline/runtime-store.json b/docs/.generated/plugin-sdk-api-baseline/runtime-store.json index 7b0ea384c738..0db44273cfb3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/runtime-store.json +++ b/docs/.generated/plugin-sdk-api-baseline/runtime-store.json @@ -1 +1 @@ -{"contentHash":"babd3de9e3fbbc52a328d73ae815f4df4dbc57377aa0a0ec5de08636b87ab542","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"} +{"contentHash":"e5c3af4aa1203cb003313ee64507c51380fd90ea50bd332f573851805aa35065","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"} diff --git a/docs/.generated/plugin-sdk-api-baseline/runtime.json b/docs/.generated/plugin-sdk-api-baseline/runtime.json index 75ccb4ac684b..5791c49e21b0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/runtime.json @@ -1 +1 @@ -{"contentHash":"98624c94e8f5dd159bca518241ab8afc63e7799d507d2ec0b611247aa3f655d3","entrypoint":"runtime","importSpecifier":"openclaw/plugin-sdk/runtime"} +{"contentHash":"c85adae45098dae23a281d3070c77253ab999b7b68bd67c4f98e5f739f0b3d2d","entrypoint":"runtime","importSpecifier":"openclaw/plugin-sdk/runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json b/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json index 0051ef28029a..1c4d17610e93 100644 --- a/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json @@ -1 +1 @@ -{"contentHash":"9b07f560a2a642bc9a9f5648d87219cacdddfe6313a472522b98fbc7a3ce482a","entrypoint":"secret-input-runtime","importSpecifier":"openclaw/plugin-sdk/secret-input-runtime"} +{"contentHash":"c1fb9ac5974e66042ee2aa16358cd9fd07585cfa701fb38116a00bfc858f1254","entrypoint":"secret-input-runtime","importSpecifier":"openclaw/plugin-sdk/secret-input-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json b/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json index 517a38da9311..53fed0f9c0fa 100644 --- a/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json @@ -1 +1 @@ -{"contentHash":"cf89c569d04b860dfc9e363c1b12c15072779459aa90e33f11f0d8305ab1336d","entrypoint":"secret-ref-runtime","importSpecifier":"openclaw/plugin-sdk/secret-ref-runtime"} +{"contentHash":"5851ad5d92fa229f8ade29ad40ede21f0ec4b4899e9ed4521642ebcd7b968a7a","entrypoint":"secret-ref-runtime","importSpecifier":"openclaw/plugin-sdk/secret-ref-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/security-runtime.json b/docs/.generated/plugin-sdk-api-baseline/security-runtime.json index 6eaafdeaf00a..b41d8bb5e111 100644 --- a/docs/.generated/plugin-sdk-api-baseline/security-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/security-runtime.json @@ -1 +1 @@ -{"contentHash":"d293aefd15df648be5808cdbe6194cb363cc3410e9e0ae01cf1d2162f8a475c5","entrypoint":"security-runtime","importSpecifier":"openclaw/plugin-sdk/security-runtime"} +{"contentHash":"037e9401d723c1fd180483e8a4096fe33d9d544a6b85bccc4500dfa6d3b41eba","entrypoint":"security-runtime","importSpecifier":"openclaw/plugin-sdk/security-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/session-catalog.json b/docs/.generated/plugin-sdk-api-baseline/session-catalog.json index 0138ede67eee..c95a732a1ff8 100644 --- a/docs/.generated/plugin-sdk-api-baseline/session-catalog.json +++ b/docs/.generated/plugin-sdk-api-baseline/session-catalog.json @@ -1 +1 @@ -{"contentHash":"e843ae3223098e08b57d4af6efd78cc1693bb5ed3b7f589dee10c223d914b1bb","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"} +{"contentHash":"8cca3d20cbf6e5860023858acd6dbd1b727c98401cdab28d0bd52424ef091343","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"} diff --git a/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json b/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json index 661dc195c215..e9a2be43bb15 100644 --- a/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json @@ -1 +1 @@ -{"contentHash":"e1f5b6c7f7c9fe19fe73ec026c9577a93b3a39d024d6083f16914f601cb766af","entrypoint":"session-store-runtime","importSpecifier":"openclaw/plugin-sdk/session-store-runtime"} +{"contentHash":"3755c6440828d4b9a0ab9edd8740ad7d7ece7da5918c13db0ac2205b617fe51f","entrypoint":"session-store-runtime","importSpecifier":"openclaw/plugin-sdk/session-store-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/setup-runtime.json b/docs/.generated/plugin-sdk-api-baseline/setup-runtime.json index 4f19e1307574..2146a60985b1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/setup-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/setup-runtime.json @@ -1 +1 @@ -{"contentHash":"bafe3559e5a656effef560996e2a5445c6d7fd8bf94ad4394b422b9396c994ec","entrypoint":"setup-runtime","importSpecifier":"openclaw/plugin-sdk/setup-runtime"} +{"contentHash":"14dbedccb17539e2a86889441af8a3254dc5e43c021f6d771ba60c40941e0c7e","entrypoint":"setup-runtime","importSpecifier":"openclaw/plugin-sdk/setup-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/setup.json b/docs/.generated/plugin-sdk-api-baseline/setup.json index eebd4aee3db2..55d0ce14c69a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/setup.json +++ b/docs/.generated/plugin-sdk-api-baseline/setup.json @@ -1 +1 @@ -{"contentHash":"af54dee897be0a016bc5844842a0449ac0609a02737495f79a7ac250fc594328","entrypoint":"setup","importSpecifier":"openclaw/plugin-sdk/setup"} +{"contentHash":"b3dc2896f2e8735c2407955011c792f4594753ce7ff6cce37048ad4e1054d6c6","entrypoint":"setup","importSpecifier":"openclaw/plugin-sdk/setup"} diff --git a/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json b/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json index 86d1ef437279..3977570b7bde 100644 --- a/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json @@ -1 +1 @@ -{"contentHash":"1ffb1002273fa4523e0bcc5c49183627d32584c32f3cade613a9de28db8ebb45","entrypoint":"skill-commands-runtime","importSpecifier":"openclaw/plugin-sdk/skill-commands-runtime"} +{"contentHash":"59dd16f1b775f05a1381976a3c2162b183a126b98a8d2254cf2f1067454a70f3","entrypoint":"skill-commands-runtime","importSpecifier":"openclaw/plugin-sdk/skill-commands-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/speech-settings.json b/docs/.generated/plugin-sdk-api-baseline/speech-settings.json index 79ef847656a3..b2fdeadb128b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/speech-settings.json +++ b/docs/.generated/plugin-sdk-api-baseline/speech-settings.json @@ -1 +1 @@ -{"contentHash":"a9f2ab866e225f38f006ff1aa6eb33b5316454f463aa4f6d6f2fd4ecdec41135","entrypoint":"speech-settings","importSpecifier":"openclaw/plugin-sdk/speech-settings"} +{"contentHash":"969e455aaa7bf83626bfbc8fe87b06669d7cc15f2fe960f0c2aca6c73cee51ee","entrypoint":"speech-settings","importSpecifier":"openclaw/plugin-sdk/speech-settings"} diff --git a/docs/.generated/plugin-sdk-api-baseline/ssrf-policy.json b/docs/.generated/plugin-sdk-api-baseline/ssrf-policy.json index bf5d2f79c5fc..4377dde35a57 100644 --- a/docs/.generated/plugin-sdk-api-baseline/ssrf-policy.json +++ b/docs/.generated/plugin-sdk-api-baseline/ssrf-policy.json @@ -1 +1 @@ -{"contentHash":"f15daa0e828f7f32901d14f7174b31883799c1bd6c1498d4295a85cbd045eeb6","entrypoint":"ssrf-policy","importSpecifier":"openclaw/plugin-sdk/ssrf-policy"} +{"contentHash":"5a662783d48ca7e7f99cb7ce41294c9417c65ae54de78468181b2fadb86d660a","entrypoint":"ssrf-policy","importSpecifier":"openclaw/plugin-sdk/ssrf-policy"} diff --git a/docs/.generated/plugin-sdk-api-baseline/ssrf-runtime.json b/docs/.generated/plugin-sdk-api-baseline/ssrf-runtime.json index 51c9c6382458..f29eeb0f113c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/ssrf-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/ssrf-runtime.json @@ -1 +1 @@ -{"contentHash":"9df7566aad3ff6c1d41e693351d47e09f77f30a0b6d43a828afe7439b84fe36f","entrypoint":"ssrf-runtime","importSpecifier":"openclaw/plugin-sdk/ssrf-runtime"} +{"contentHash":"fac54b44bf1db4eb5cf44027dd37a0459835687a7fc8924de8d78133c836a9a2","entrypoint":"ssrf-runtime","importSpecifier":"openclaw/plugin-sdk/ssrf-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/status-helpers.json b/docs/.generated/plugin-sdk-api-baseline/status-helpers.json index 0246ce841f5b..b034e214ab8d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/status-helpers.json +++ b/docs/.generated/plugin-sdk-api-baseline/status-helpers.json @@ -1 +1 @@ -{"contentHash":"a3217185d285139c7c36f69378fb80bd2a8287beeb0a35b7f1fc87d80da88264","entrypoint":"status-helpers","importSpecifier":"openclaw/plugin-sdk/status-helpers"} +{"contentHash":"1973f6312e0eb369aa69c8ce299f7b663f602fd4b2dd9b1c06092da34e007f3b","entrypoint":"status-helpers","importSpecifier":"openclaw/plugin-sdk/status-helpers"} diff --git a/docs/.generated/plugin-sdk-api-baseline/telegram-account.json b/docs/.generated/plugin-sdk-api-baseline/telegram-account.json index c68d808167c0..a5d03f358b72 100644 --- a/docs/.generated/plugin-sdk-api-baseline/telegram-account.json +++ b/docs/.generated/plugin-sdk-api-baseline/telegram-account.json @@ -1 +1 @@ -{"contentHash":"887bb7b047b997f5eeb948a8fe8f79013580ffc3b62cb6cee10cc017908ee759","entrypoint":"telegram-account","importSpecifier":"openclaw/plugin-sdk/telegram-account"} +{"contentHash":"bf5c0bae97585234806409bf1350ab81635397c74594d9d34eb2b3f5e7c2eb99","entrypoint":"telegram-account","importSpecifier":"openclaw/plugin-sdk/telegram-account"} diff --git a/docs/.generated/plugin-sdk-api-baseline/text-runtime.json b/docs/.generated/plugin-sdk-api-baseline/text-runtime.json index 7291cc51ecf1..b02340f70ad7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/text-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/text-runtime.json @@ -1 +1 @@ -{"contentHash":"41b78aa69035e6b7dc97d62553f964d98065b60604d85d5ce5acd4c4bfc55c8a","entrypoint":"text-runtime","importSpecifier":"openclaw/plugin-sdk/text-runtime"} +{"contentHash":"8c7f1c1933597fc39fc6b5b9e0fd75311cb4142ab3ce40e4fbbfe2b97af0ba51","entrypoint":"text-runtime","importSpecifier":"openclaw/plugin-sdk/text-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index f30fd3b54b33..c32da027a2af 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"af55168f90db0f1d2d55a38b116a8612710b90874418fc238827619b87a89691","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"3bd16cfa9be68c8d517d1a51956f588482e45ef70ea39fb192b2129e9f433ffe","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 67edf2b3a758..a640c2c33eae 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"eaf35330803af512853cf00050e5d6fe5e1e86f9997134e36146ea3cf6ba4595","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"c8b52887a5faf418866ba16c3b71d8f62509f7eb9231e30cd39f4cd331d3e7f1","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-request-guards.json b/docs/.generated/plugin-sdk-api-baseline/webhook-request-guards.json index 9943c3c61ddc..68df74cb7f59 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-request-guards.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-request-guards.json @@ -1 +1 @@ -{"contentHash":"179917aa0e669336c4b7a6e478e5ff5e4f826bd4ebf9323310a4f539b1272df9","entrypoint":"webhook-request-guards","importSpecifier":"openclaw/plugin-sdk/webhook-request-guards"} +{"contentHash":"602192eac98a49083f8cd45812042fb47265d79d88423854840ba79bff0e1d44","entrypoint":"webhook-request-guards","importSpecifier":"openclaw/plugin-sdk/webhook-request-guards"} diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 2257c355ab97..75da7af69dee 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -567,6 +567,47 @@ See [Plugins](/tools/plugin). --- +## Desktop + +The host desktop source lets the Control UI Desktop panel connect to an RFB +server already running on the Gateway machine. It is a Labs feature and is off +by default. + +```json5 +{ + desktop: { + host: { + enabled: true, + port: 5900, + // passwordFile: "/path/to/vnc-password.txt", + }, + }, +} +``` + +- `desktop.host.enabled`: advertises **This machine** as a desktop source after + the Gateway restarts. +- `desktop.host.port`: loopback RFB port on `127.0.0.1` (default: `5900`). +- `desktop.host.passwordFile`: optional UTF-8 VNC password file. Without it, + the Control UI prompts for a VNC password and keeps it in browser memory for + that connection. + +OpenClaw connects only through loopback and does not install or manage a VNC +server. Configure third-party servers to listen on loopback when they support +it. On Linux, use a loopback-only TigerVNC or `x11vnc` listener; GNOME Remote +Desktop's VeNCrypt mode is not supported. On Windows, enable VNC authentication +and loopback access in the VNC server. + +On macOS, enable **System Settings → General → Sharing → Screen Sharing**. +Modern Screen Sharing uses ARD account authentication, so the Gateway performs +that handshake and gives the browser an already-authenticated no-auth RFB +stream. The macOS account password is not returned in the observe result, URL, +or logs. `openclaw doctor` can offer an explicitly confirmed `sudo launchctl` +repair when Screen Sharing is off; enabling the macOS system service may expose +it on other network interfaces according to macOS Sharing settings. + +--- + ## Gateway ```json5 diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 541c47409065..47fb10279488 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -143,6 +143,10 @@ export { WorkerDesktopObserveResultSchema, WorkerDesktopLaunchParamsSchema, WorkerDesktopLaunchResultSchema, + DesktopSourceSchema, + DesktopObserveParamsSchema, + DesktopObserveResultSchema, + DesktopLaunchParamsSchema, SystemInfoParamsSchema, SystemInfoResultSchema, StateVersionSchema, @@ -684,18 +688,4 @@ export { PROTOCOL_VERSION, } from "./version.js"; export type * from "./schema-types.js"; - -// Local structural result keeps this package independent of core session types. -export type SessionsPatchResult = { - ok: true; - path: string; - key: string; - entry: Record; - resolved?: { - modelProvider?: string; - model?: string; - agentRuntime?: import("./schema/agents-models-skills.js").GatewayAgentRuntime; - thinkingLevel?: string; - thinkingLevels?: Array<{ id: string; label: string }>; - }; -}; +export type { SessionsPatchResult } from "./sessions-patch-result.js"; diff --git a/packages/gateway-protocol/src/schema-modules.ts b/packages/gateway-protocol/src/schema-modules.ts index fca55b44597b..264fa2b433ca 100644 --- a/packages/gateway-protocol/src/schema-modules.ts +++ b/packages/gateway-protocol/src/schema-modules.ts @@ -22,6 +22,7 @@ export * from "./schema/error-codes.js"; export * from "./schema/environments.js"; export * from "./schema/exec-approvals.js"; export * from "./schema/devices.js"; +export * from "./schema/desktop.js"; export * from "./schema/frames.js"; export * from "./schema/fs.js"; export * from "./schema/gateway-suspend.js"; diff --git a/packages/gateway-protocol/src/schema/desktop.test.ts b/packages/gateway-protocol/src/schema/desktop.test.ts new file mode 100644 index 000000000000..cc4d7de55c84 --- /dev/null +++ b/packages/gateway-protocol/src/schema/desktop.test.ts @@ -0,0 +1,70 @@ +import { Value } from "typebox/value"; +import { describe, expect, it } from "vitest"; +import { + DesktopLaunchParamsSchema, + DesktopObserveResultSchema, + validateDesktopObserveParams, +} from "../index.js"; + +describe("desktop protocol schemas", () => { + it("accepts host and environment observe sources while rejecting unknown source kinds", () => { + expect(validateDesktopObserveParams({ source: { kind: "host" }, control: true })).toBe(true); + expect( + validateDesktopObserveParams({ + source: { kind: "host" }, + credentials: { username: "operator", password: "secret" }, + }), + ).toBe(true); + expect( + validateDesktopObserveParams({ + source: { kind: "environment", environmentId: "worker:one" }, + }), + ).toBe(true); + expect(validateDesktopObserveParams({ source: { kind: "node", nodeId: "one" } })).toBe(false); + expect( + validateDesktopObserveParams({ + source: { kind: "environment", environmentId: "worker:one" }, + credentials: { password: "secret" }, + }), + ).toBe(false); + expect( + validateDesktopObserveParams({ + source: { kind: "host" }, + credentials: { username: "", password: "secret" }, + }), + ).toBe(false); + expect(validateDesktopObserveParams({ source: { kind: "host", environmentId: "one" } })).toBe( + false, + ); + }); + + it("keeps launch environment-only and desktop auth additive", () => { + expect( + Value.Check(DesktopLaunchParamsSchema, { + source: { kind: "environment", environmentId: "worker:one" }, + app: "browser", + }), + ).toBe(true); + expect( + Value.Check(DesktopLaunchParamsSchema, { source: { kind: "host" }, app: "browser" }), + ).toBe(false); + expect( + Value.Check(DesktopObserveResultSchema, { + transport: "rfb", + wsPath: "/desktop/observe?token=abc", + expiresAtMs: 1, + control: false, + auth: "ard-account", + }), + ).toBe(true); + expect( + Value.Check(DesktopObserveResultSchema, { + transport: "rfb", + wsPath: "/desktop/observe?token=abc", + expiresAtMs: 1, + control: false, + auth: "vencrypt", + }), + ).toBe(false); + }); +}); diff --git a/packages/gateway-protocol/src/schema/desktop.ts b/packages/gateway-protocol/src/schema/desktop.ts new file mode 100644 index 000000000000..593b85715d4c --- /dev/null +++ b/packages/gateway-protocol/src/schema/desktop.ts @@ -0,0 +1,49 @@ +// Gateway Protocol schema module defines source-agnostic desktop validation shapes. +import { Type, type Static } from "typebox"; +import { closedObject } from "./closed-object.js"; +import { WorkerDesktopAppIdSchema } from "./environments.js"; +import { NonEmptyString } from "./primitives.js"; + +// Desktop sources are additive; node and future source kinds append new union arms. +export const DesktopSourceSchema = Type.Union([ + closedObject({ kind: Type.Literal("host") }), + closedObject({ kind: Type.Literal("environment"), environmentId: NonEmptyString }), +]); + +const DesktopObserveCredentialsSchema = closedObject({ + username: Type.Optional(NonEmptyString), + password: Type.Optional(NonEmptyString), +}); + +export const DesktopObserveParamsSchema = Type.Union([ + closedObject({ + source: closedObject({ kind: Type.Literal("host") }), + control: Type.Optional(Type.Boolean()), + // Credentials exist only for this observe attempt and are never persisted or returned. + credentials: Type.Optional(DesktopObserveCredentialsSchema), + }), + closedObject({ + source: closedObject({ kind: Type.Literal("environment"), environmentId: NonEmptyString }), + control: Type.Optional(Type.Boolean()), + }), +]); + +export const DesktopObserveResultSchema = closedObject({ + transport: Type.String({ enum: ["rfb"] }), + wsPath: NonEmptyString, + expiresAtMs: Type.Integer({ minimum: 0 }), + control: Type.Boolean(), + vncPassword: Type.Optional(NonEmptyString), + // Auth drives credential prompting without coupling clients to RFB security numbers. + auth: Type.Optional(Type.String({ enum: ["none", "vnc-password", "ard-account"] })), +}); + +export const DesktopLaunchParamsSchema = closedObject({ + source: closedObject({ kind: Type.Literal("environment"), environmentId: NonEmptyString }), + app: WorkerDesktopAppIdSchema, +}); + +export type DesktopSource = Static; +export type DesktopObserveParams = Static; +export type DesktopObserveResult = Static; +export type DesktopLaunchParams = Static; diff --git a/packages/gateway-protocol/src/schema/environments.ts b/packages/gateway-protocol/src/schema/environments.ts index c1b0d5ce3a44..5b66cbbf81e0 100644 --- a/packages/gateway-protocol/src/schema/environments.ts +++ b/packages/gateway-protocol/src/schema/environments.ts @@ -73,6 +73,7 @@ function createEnvironmentSummarySchema() { sessionHost: Type.Optional(Type.Boolean()), trust: Type.Optional(EnvironmentTrustSchema), capabilities: Type.Optional(Type.Array(NonEmptyString)), + desktop: Type.Optional(Type.Boolean()), worker: Type.Optional(WorkerEnvironmentMetadataSchema), }); } diff --git a/packages/gateway-protocol/src/schema/protocol-schema-fragment-agent-control.ts b/packages/gateway-protocol/src/schema/protocol-schema-fragment-agent-control.ts index 2e61aac04f96..9fba5041df5c 100644 --- a/packages/gateway-protocol/src/schema/protocol-schema-fragment-agent-control.ts +++ b/packages/gateway-protocol/src/schema/protocol-schema-fragment-agent-control.ts @@ -1,4 +1,5 @@ import * as agent from "./agent.js"; +import * as desktop from "./desktop.js"; import * as environments from "./environments.js"; import * as fsSchemas from "./fs.js"; import * as projects from "./projects.js"; @@ -26,6 +27,10 @@ export const AgentControlProtocolSchemas = { WorkerDesktopLaunchResult: environments.WorkerDesktopLaunchResultSchema, ProjectCheckout: projects.ProjectCheckoutSchema, ProjectSummary: projects.ProjectSummarySchema, + DesktopSource: desktop.DesktopSourceSchema, + DesktopObserveParams: desktop.DesktopObserveParamsSchema, + DesktopObserveResult: desktop.DesktopObserveResultSchema, + DesktopLaunchParams: desktop.DesktopLaunchParamsSchema, SystemInfoParams: systemInfo.SystemInfoParamsSchema, SystemInfoResult: systemInfo.SystemInfoResultSchema, AgentEvent: agent.AgentEventSchema, diff --git a/packages/gateway-protocol/src/sessions-patch-result.ts b/packages/gateway-protocol/src/sessions-patch-result.ts new file mode 100644 index 000000000000..25efdd0ce9cf --- /dev/null +++ b/packages/gateway-protocol/src/sessions-patch-result.ts @@ -0,0 +1,14 @@ +// Local structural result keeps this package independent of core session types. +export type SessionsPatchResult = { + ok: true; + path: string; + key: string; + entry: Record; + resolved?: { + modelProvider?: string; + model?: string; + agentRuntime?: import("./schema/agents-models-skills.js").GatewayAgentRuntime; + thinkingLevel?: string; + thinkingLevels?: Array<{ id: string; label: string }>; + }; +}; diff --git a/packages/gateway-protocol/src/validator-registry.ts b/packages/gateway-protocol/src/validator-registry.ts index 5a4feeec1316..254e0a205f47 100644 --- a/packages/gateway-protocol/src/validator-registry.ts +++ b/packages/gateway-protocol/src/validator-registry.ts @@ -159,6 +159,9 @@ export const validateWorkerDesktopObserveParams = compile(S.WorkerDesktopObserve export const validateWorkerDesktopObserveResult = compile(S.WorkerDesktopObserveResultSchema); export const validateWorkerDesktopLaunchParams = compile(S.WorkerDesktopLaunchParamsSchema); export const validateWorkerDesktopLaunchResult = compile(S.WorkerDesktopLaunchResultSchema); +export const validateDesktopObserveParams = compile(S.DesktopObserveParamsSchema); +export const validateDesktopObserveResult = compile(S.DesktopObserveResultSchema); +export const validateDesktopLaunchParams = compile(S.DesktopLaunchParamsSchema); export const validateSystemInfoParams = compile(S.SystemInfoParamsSchema); export const validateSystemInfoResult = compile(S.SystemInfoResultSchema); export const validateNodePendingAckParams = compile(S.NodePendingAckParamsSchema); diff --git a/scripts/check-env-var-count.mts b/scripts/check-env-var-count.mts index aeae169a0ece..aa8dc5ceea39 100644 --- a/scripts/check-env-var-count.mts +++ b/scripts/check-env-var-count.mts @@ -83,6 +83,15 @@ function readBaseBudget(root: string, ref: string) { encoding: "utf8", }); const baselineRef = mergeBase.stdout.trim(); + // Exit 1 with no output is git reporting no shared ancestor; a real failure exits 128. + // Shallow clones and grafted agent checkouts resolve the ref but truncate history, and + // only the growth comparison needs a baseline, so skip it rather than failing the gate. + if (mergeBase.status === 1 && !baselineRef) { + process.stderr.write( + `[env-var-count] ${ref} shares no reachable ancestor here; skipping the base-budget comparison\n`, + ); + return null; + } if (mergeBase.status !== 0 || !baselineRef) { throw new Error(`Could not resolve env-var count merge base for: ${ref}`); } diff --git a/scripts/check-protocol-registry.mts b/scripts/check-protocol-registry.mts index 67bbdf121c9d..8da7b07f4be0 100644 --- a/scripts/check-protocol-registry.mts +++ b/scripts/check-protocol-registry.mts @@ -113,8 +113,8 @@ const ownerModules = [ ...schemaModulesSource.matchAll(/^export \* from "\.\/schema\/([^"]+)\.js";$/gmu), ].map(([, moduleName = ""]) => moduleName); check( - ownerModules.length === 55 && new Set(ownerModules).size === ownerModules.length, - "schema-modules.ts must contain one unique 55-module owner list", + ownerModules.length === 56 && new Set(ownerModules).size === ownerModules.length, + "schema-modules.ts must contain one unique 56-module owner list", ); check( schemaModulesSource.split("\n").filter(Boolean).length === ownerModules.length, diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 8d3a99fed8c7..0015b9d4a566 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -29,6 +29,7 @@ import { projectGatewayUrlForDiagnostics } from "../../gateway/connection-detail import { resolveAdvertisedControlUiLinks } from "../../gateway/control-ui-links.js"; import { gatewaySecretInputPathCanWin } from "../../gateway/credentials-secret-inputs.js"; import { trimToUndefined } from "../../gateway/credentials.js"; +import type { HostDesktopStatus } from "../../gateway/desktop/host-source.js"; import { resolveGatewayRequiredListenHosts } from "../../gateway/net.js"; import { resolveGatewayProbeCredentialConfig } from "../../gateway/probe-auth.js"; import { @@ -312,6 +313,7 @@ export type DaemonStatus = { mismatch?: boolean; }; gateway?: GatewayStatusSummary; + hostDesktop?: HostDesktopStatus; port?: { port: number; status: PortUsageStatus; @@ -792,6 +794,10 @@ export async function gatherDaemonStatus( } } + const hostDesktop = await ( + await import("../../gateway/desktop/host-source.js") + ).inspectHostDesktop({ config: daemonCfg.desktop?.host }); + return { cli: resolveCliStatusSummary(), logFile: resolveConfiguredLogFilePath(cliCfg), @@ -824,6 +830,7 @@ export async function gatherDaemonStatus( } : {}), }, + hostDesktop: hostDesktop.status, port: portStatus, ...(portCliStatus ? { portCli: portCliStatus } : {}), ...(establishedClients ? { connections: establishedClients } : {}), diff --git a/src/cli/daemon-cli/status.print.test.ts b/src/cli/daemon-cli/status.print.test.ts index b2a757043c83..17706e03bfc8 100644 --- a/src/cli/daemon-cli/status.print.test.ts +++ b/src/cli/daemon-cli/status.print.test.ts @@ -107,6 +107,26 @@ describe("printDaemonStatus", () => { isWSLEnvMock.mockClear(); }); + it("prints host desktop state and auth type", () => { + printDaemonStatus( + { + service: { + label: "LaunchAgent", + loaded: true, + loadedText: "loaded", + notLoadedText: "not loaded", + }, + hostDesktop: { enabled: true, state: "attached", port: 5900, security: "VncAuth" }, + extraServices: [], + }, + { json: false }, + ); + expectMockLineContains( + runtime.log, + "Host desktop: attached · 127.0.0.1:5900 · security VncAuth", + ); + }); + it("prints the applied Gateway heap limit and derivation", () => { printDaemonStatus( { diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index 90e72af8afa1..ad2180a2aa18 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -135,6 +135,16 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean; d `${label("Gateway heap:")} ${infoText(formatGatewayHeapLimitReport(service.gatewayHeap))}`, ); } + const hostDesktop = status.hostDesktop ?? { + enabled: false, + state: "disabled" as const, + port: 5900, + }; + const hostDesktopValue = + hostDesktop.state === "disabled" + ? "disabled" + : `${hostDesktop.state} · 127.0.0.1:${hostDesktop.port}${hostDesktop.security ? ` · security ${hostDesktop.security}` : ""}`; + defaultRuntime.log(`${label("Host desktop:")} ${infoText(hostDesktopValue)}`); spacer(); if (service.configAudit?.issues.length) { diff --git a/src/commands/doctor-host-desktop.test.ts b/src/commands/doctor-host-desktop.test.ts new file mode 100644 index 000000000000..e62ec9f4a2f1 --- /dev/null +++ b/src/commands/doctor-host-desktop.test.ts @@ -0,0 +1,150 @@ +import net from "node:net"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { note } from "../../packages/terminal-core/src/note.js"; +import * as hostSource from "../gateway/desktop/host-source.js"; +import { noteHostDesktopHealth } from "./doctor-host-desktop.js"; + +vi.mock("../../packages/terminal-core/src/note.js", () => ({ note: vi.fn() })); + +const cleanups: Array<() => Promise> = []; + +afterEach(async () => { + vi.mocked(note).mockReset(); + vi.restoreAllMocks(); + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); +}); + +const unavailableInspection: hostSource.HostDesktopInspection = { + status: { enabled: true, state: "unavailable", port: 5900 }, + detail: + "gateway host desktop is unavailable at 127.0.0.1:5900. Enable System Settings -> General -> Sharing -> Screen Sharing.", + unavailableReason: "not-listening", +}; + +function commandResult(code: number) { + return { + stdout: "", + stderr: "", + code, + signal: null, + killed: false, + termination: "exit" as const, + }; +} + +describe("host desktop doctor section", () => { + it("reports the disabled Labs toggle", async () => { + await noteHostDesktopHealth({}); + expect(note).toHaveBeenCalledWith( + "disabled; enable the Desktop lab with desktop.host.enabled=true, then restart the gateway", + "Host desktop", + ); + }); + + it("reports an attached VncAuth loopback server without password material", async () => { + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + socket.write(Buffer.from("RFB 003.008\n", "ascii")); + socket.once("data", () => socket.write(Buffer.from([1, 2]))); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("expected RFB address"); + } + cleanups.push( + async () => + await new Promise((resolve) => { + for (const socket of sockets) { + socket.destroy(); + } + server.close(() => resolve()); + }), + ); + + await noteHostDesktopHealth({ desktop: { host: { enabled: true, port: address.port } } }); + expect(note).toHaveBeenCalledWith( + `attached (127.0.0.1:${address.port}, security: VncAuth)`, + "Host desktop", + ); + }); + + it("runs the exact Screen Sharing launchctl repair only after interactive confirmation", async () => { + vi.spyOn(hostSource, "inspectHostDesktop") + .mockResolvedValueOnce(unavailableInspection) + .mockResolvedValueOnce({ + status: { enabled: true, state: "attached", port: 5900, security: "ARD" }, + detail: "attached (127.0.0.1:5900, security: ARD)", + }); + const confirmRuntimeRepair = vi.fn(async () => true); + const runCommand = vi.fn(async (_argv: string[], _options: unknown) => commandResult(0)); + + await noteHostDesktopHealth( + { desktop: { host: { enabled: true } } }, + { + platform: "darwin", + prompter: { shouldRepair: true, confirmRuntimeRepair }, + runCommand, + }, + ); + + expect(confirmRuntimeRepair).toHaveBeenCalledWith({ + message: + "Enable macOS Screen Sharing now using sudo launchctl? This system service may accept connections from other network interfaces according to macOS Sharing settings.", + initialValue: false, + requiresInteractiveConfirmation: true, + }); + expect(runCommand.mock.calls.map(([argv]) => argv)).toEqual([ + ["sudo", "launchctl", "enable", "system/com.apple.screensharing"], + ["sudo", "launchctl", "kickstart", "-k", "system/com.apple.screensharing"], + ]); + expect(note).toHaveBeenCalledWith("attached (127.0.0.1:5900, security: ARD)", "Host desktop"); + }); + + it("prints the System Settings path when interactive repair is declined", async () => { + vi.spyOn(hostSource, "inspectHostDesktop").mockResolvedValue(unavailableInspection); + const runCommand = vi.fn(); + await noteHostDesktopHealth( + { desktop: { host: { enabled: true } } }, + { + platform: "darwin", + prompter: { shouldRepair: true, confirmRuntimeRepair: vi.fn(async () => false) }, + runCommand: runCommand as never, + }, + ); + expect(runCommand).not.toHaveBeenCalled(); + expect(note).toHaveBeenCalledWith( + "Enable Screen Sharing manually in System Settings → General → Sharing → Screen Sharing.", + "Host desktop repair", + ); + }); + + it("stops after a failed sudo command and prints both manual repair paths", async () => { + vi.spyOn(hostSource, "inspectHostDesktop").mockResolvedValue(unavailableInspection); + const runCommand = vi.fn(async () => commandResult(1)); + await noteHostDesktopHealth( + { desktop: { host: { enabled: true } } }, + { + platform: "darwin", + prompter: { shouldRepair: true, confirmRuntimeRepair: vi.fn(async () => true) }, + runCommand, + }, + ); + expect(runCommand).toHaveBeenCalledTimes(1); + expect(note).toHaveBeenCalledWith( + expect.stringContaining( + "sudo launchctl enable system/com.apple.screensharing && sudo launchctl kickstart -k system/com.apple.screensharing", + ), + "Host desktop repair", + ); + expect(note).toHaveBeenCalledWith( + expect.stringContaining("System Settings → General → Sharing → Screen Sharing"), + "Host desktop repair", + ); + }); +}); diff --git a/src/commands/doctor-host-desktop.ts b/src/commands/doctor-host-desktop.ts new file mode 100644 index 000000000000..2f0baaa24d76 --- /dev/null +++ b/src/commands/doctor-host-desktop.ts @@ -0,0 +1,91 @@ +import { note } from "../../packages/terminal-core/src/note.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { HealthFinding } from "../flows/health-checks.js"; +import { inspectHostDesktop } from "../gateway/desktop/host-source.js"; +import { runCommandWithTimeout } from "../process/exec-runner.js"; +import type { DoctorPrompter } from "./doctor-prompter.js"; + +const SCREEN_SHARING_PORT = 5900; +const SCREEN_SHARING_COMMAND = + "sudo launchctl enable system/com.apple.screensharing && sudo launchctl kickstart -k system/com.apple.screensharing"; +const SCREEN_SHARING_SETTINGS = "System Settings → General → Sharing → Screen Sharing"; + +/** Collects the non-mutating host desktop diagnostic shared by doctor modes. */ +export async function collectHostDesktopHealthFindings( + cfg: OpenClawConfig, +): Promise { + const inspection = await inspectHostDesktop({ config: cfg.desktop?.host }); + return [ + { + checkId: "core/doctor/host-desktop", + severity: inspection.status.state === "unavailable" ? "warning" : "info", + message: inspection.detail, + path: "desktop.host", + }, + ]; +} + +/** Renders host desktop health and offers an explicitly confirmed macOS service repair. */ +export async function noteHostDesktopHealth( + cfg: OpenClawConfig, + deps: { + platform?: NodeJS.Platform; + prompter?: Pick; + runCommand?: typeof runCommandWithTimeout; + } = {}, +): Promise { + const platform = deps.platform ?? process.platform; + const inspection = await inspectHostDesktop({ config: cfg.desktop?.host, platform }); + const finding: HealthFinding = { + checkId: "core/doctor/host-desktop", + severity: inspection.status.state === "unavailable" ? "warning" : "info", + message: inspection.detail, + path: "desktop.host", + }; + note(finding.message, "Host desktop"); + if ( + platform !== "darwin" || + cfg.desktop?.host?.enabled !== true || + inspection.status.port !== SCREEN_SHARING_PORT || + inspection.unavailableReason !== "not-listening" + ) { + return; + } + + note( + `Repair command: ${SCREEN_SHARING_COMMAND}\nManual path: ${SCREEN_SHARING_SETTINGS}`, + "Host desktop repair", + ); + if (!deps.prompter?.shouldRepair) { + return; + } + // Screen Sharing is a macOS system service and may listen beyond loopback. + // Keep activation explicit; the Gateway itself only connects to 127.0.0.1. + const approved = await deps.prompter.confirmRuntimeRepair({ + message: + "Enable macOS Screen Sharing now using sudo launchctl? This system service may accept connections from other network interfaces according to macOS Sharing settings.", + initialValue: false, + requiresInteractiveConfirmation: true, + }); + if (!approved) { + note(`Enable Screen Sharing manually in ${SCREEN_SHARING_SETTINGS}.`, "Host desktop repair"); + return; + } + + const runCommand = deps.runCommand ?? runCommandWithTimeout; + for (const argv of [ + ["sudo", "launchctl", "enable", "system/com.apple.screensharing"], + ["sudo", "launchctl", "kickstart", "-k", "system/com.apple.screensharing"], + ]) { + const result = await runCommand(argv, { timeoutMs: 120_000 }); + if (result.code !== 0) { + note( + `Screen Sharing repair failed. Run ${SCREEN_SHARING_COMMAND}, or enable it in ${SCREEN_SHARING_SETTINGS}.`, + "Host desktop repair", + ); + return; + } + } + const repaired = await inspectHostDesktop({ config: cfg.desktop.host, platform }); + note(repaired.detail, "Host desktop"); +} diff --git a/src/commands/status-overview-rows.test.ts b/src/commands/status-overview-rows.test.ts index 040ebf48f44a..78eeadb5198b 100644 --- a/src/commands/status-overview-rows.test.ts +++ b/src/commands/status-overview-rows.test.ts @@ -23,6 +23,7 @@ describe("status-overview-rows", () => { "1 files · 2 chunks · plugin memory · ok(vector ready) · warn(fts ready) · muted(cache warm)", ); expect(findRowValue(rows, "Plugin compatibility")).toBe("warn(1 notice · 1 plugin)"); + expect(findRowValue(rows, "Host desktop")).toBe("muted(disabled)"); expect(findRowValue(rows, "Sessions")).toBe( "2 active · default gpt-5.5 (12k ctx) · store.json", ); diff --git a/src/commands/status-overview-rows.ts b/src/commands/status-overview-rows.ts index 0f3c9581560e..d6d957cda754 100644 --- a/src/commands/status-overview-rows.ts +++ b/src/commands/status-overview-rows.ts @@ -119,6 +119,15 @@ export function buildStatusCommandOverviewRows( ok: params.ok, warn: params.warn, }); + const hostDesktop = params.summary.hostDesktop ?? { + enabled: false, + state: "disabled" as const, + port: 5900, + }; + const hostDesktopValue = + hostDesktop.state === "disabled" + ? params.muted("disabled") + : `${hostDesktop.state} · 127.0.0.1:${hostDesktop.port}${hostDesktop.security ? ` · security ${hostDesktop.security}` : ""}`; return buildStatusOverviewRowsFromSurface({ surface: params.surface, decorateOk: params.ok, @@ -133,6 +142,7 @@ export function buildStatusCommandOverviewRows( ? [{ Item: "Update restart", Value: params.updateRestartValue }] : []), { Item: "Memory", Value: memoryValue }, + { Item: "Host desktop", Value: hostDesktopValue }, ...(degradedSecretsValue ? [{ Item: "Degraded secrets", Value: degradedSecretsValue }] : []), ...(degradedPluginsValue ? [{ Item: "Degraded plugins", Value: degradedPluginsValue }] : []), { Item: "Plugin compatibility", Value: pluginCompatibilityValue }, diff --git a/src/config/schema.help.core.ts b/src/config/schema.help.core.ts index 6af70295d7c2..35b9dd8eec43 100644 --- a/src/config/schema.help.core.ts +++ b/src/config/schema.help.core.ts @@ -1,6 +1,7 @@ // Defines user-facing config field help text for docs and UI surfaces. import { describeTalkSilenceTimeoutDefaults } from "./talk-defaults.js"; import { CLOUD_WORKER_FIELD_HELP } from "./zod-schema.cloud-workers.js"; +import { DESKTOP_FIELD_HELP } from "./zod-schema.desktop.js"; export const CORE_FIELD_HELP: Record = { "channels.discord.activities": @@ -75,6 +76,7 @@ export const CORE_FIELD_HELP: Record = { cloudWorkers: "Opt-in cloud worker profiles for disposable remote environments. When this section is omitted or has no profiles, cloud worker creation remains unavailable and existing gateway/node status behavior is unchanged.", ...CLOUD_WORKER_FIELD_HELP, + ...DESKTOP_FIELD_HELP, gateway: "Gateway runtime surface for bind mode, auth, control UI, remote transport, and operational safety controls. Keep conservative defaults unless you intentionally expose the gateway beyond trusted local interfaces.", "gateway.port": diff --git a/src/config/schema.hints.ts b/src/config/schema.hints.ts index 46c918555b0e..6b3368f9a6c8 100644 --- a/src/config/schema.hints.ts +++ b/src/config/schema.hints.ts @@ -23,6 +23,7 @@ const GROUP_HINTS = [ ["gateway", "Gateway", 30], ["nodeHost", "Node Host", 35], ["cloudWorkers", "Cloud Workers", 37], + ["desktop", "Desktop", 38], ["agents", "Agents", 40], ["tools", "Tools", 50], ["bindings", "Bindings", 55], @@ -85,6 +86,7 @@ const SECTION_DOCS_URLS = { voicewake: "https://docs.openclaw.ai/nodes/voicewake", presence: "https://docs.openclaw.ai/concepts/presence", cloudWorkers: "https://docs.openclaw.ai/gateway/cloud-workers", + desktop: "https://docs.openclaw.ai/gateway/configuration", worktrees: "https://docs.openclaw.ai/concepts/managed-worktrees", proxy: "https://docs.openclaw.ai/security/network-proxy", transcripts: "https://docs.openclaw.ai/plugins/meeting-plugins", diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 7f641be891c9..5a7fe758045a 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -2,6 +2,7 @@ import { MEDIA_AUDIO_FIELD_LABELS } from "./media-audio-field-metadata.js"; import { NODE_CAPABILITY_FIELD_LABELS } from "./schema.node-capabilities.js"; import { CLOUD_WORKER_FIELD_LABELS } from "./zod-schema.cloud-workers.js"; +import { DESKTOP_FIELD_LABELS } from "./zod-schema.desktop.js"; export const FIELD_LABELS: Record = { "channels.discord.activities": "Discord Activities", @@ -102,6 +103,7 @@ export const FIELD_LABELS: Record = { "agents.entries.*.agentRuntime.id": "Legacy Agent Runtime ID", cloudWorkers: "Cloud Workers", ...CLOUD_WORKER_FIELD_LABELS, + ...DESKTOP_FIELD_LABELS, gateway: "Gateway", "gateway.port": "Gateway Port", "gateway.mode": "Gateway Mode", diff --git a/src/config/schema.tiers.ts b/src/config/schema.tiers.ts index e0e838a6ac09..70dc2adc1b8b 100644 --- a/src/config/schema.tiers.ts +++ b/src/config/schema.tiers.ts @@ -3,7 +3,7 @@ import { asSchemaObject, type ConfigJsonSchemaObject } from "./schema.shared.js" const ROOT_TIER_PATHS = ` accessGroups acp agents approvals attachments auth bindings broadcast browser channels -cloudWorkers commands cron diagnostics discovery env gateway hooks logging mcp memory messages +cloudWorkers commands cron desktop diagnostics discovery env gateway hooks logging mcp memory messages meta models nodeHost plugins proxy secrets security session skills surfaces talk tools transcripts tts ui update wizard ` diff --git a/src/config/types.desktop.ts b/src/config/types.desktop.ts new file mode 100644 index 000000000000..ec211ef592a0 --- /dev/null +++ b/src/config/types.desktop.ts @@ -0,0 +1,15 @@ +// Defines the experimental gateway-host desktop source configuration. + +export type DesktopHostConfig = { + /** Enables the gateway-host desktop source after a gateway restart. */ + enabled: boolean; + /** Loopback RFB port of an already-running VNC server (default: 5900). */ + port?: number; + /** Absolute VNC password-file path; macOS ARD account credentials stay per-observation. */ + passwordFile?: string; +}; + +export type DesktopConfig = { + /** Experimental Labs gate for observing an already-running VNC server on the gateway host. */ + host?: DesktopHostConfig; +}; diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index a92e576b70d3..6f2df132be0d 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -12,6 +12,7 @@ import type { BrowserConfig } from "./types.browser.js"; import type { ChannelsConfig } from "./types.channels.js"; import type { CloudWorkersConfig } from "./types.cloud-workers.js"; import type { CronConfig } from "./types.cron.js"; +import type { DesktopConfig } from "./types.desktop.js"; import type { DiscoveryConfig, GatewayConfig, TalkConfig } from "./types.gateway.js"; import type { HooksConfig } from "./types.hooks.js"; import type { McpConfig } from "./types.mcp.js"; @@ -227,6 +228,8 @@ export type OpenClawConfig = { gateway?: GatewayConfig; /** Opt-in cloud-worker provider profiles. */ cloudWorkers?: CloudWorkersConfig; + /** Experimental desktop sources owned by the gateway host. */ + desktop?: DesktopConfig; /** Memory indexing/search configuration. */ memory?: MemoryConfig; /** MCP client/server and Codex MCP approval configuration. */ diff --git a/src/config/types.ts b/src/config/types.ts index 5295691d9760..01a6390bd85c 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -9,6 +9,7 @@ export * from "./types.auth.js"; export * from "./types.base.js"; export * from "./types.browser.js"; export * from "./types.cloud-workers.js"; +export * from "./types.desktop.js"; export * from "./types.channels.js"; export * from "./types.openclaw.js"; export * from "./types.cron.js"; diff --git a/src/config/zod-schema.desktop.test.ts b/src/config/zod-schema.desktop.test.ts new file mode 100644 index 000000000000..f3054a20cb23 --- /dev/null +++ b/src/config/zod-schema.desktop.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { computeBaseConfigSchemaResponse } from "./schema-base.js"; +import { DESKTOP_FIELD_HELP, DESKTOP_FIELD_LABELS } from "./zod-schema.desktop.js"; +import { OpenClawSchema } from "./zod-schema.js"; + +describe("OpenClawSchema desktop config", () => { + it("round-trips the host Labs config and rejects unknown or unsafe fields", () => { + expect( + OpenClawSchema.parse({ + desktop: { host: { enabled: true, port: 5901, passwordFile: "/run/vnc/passwd" } }, + }).desktop, + ).toStrictEqual({ + host: { enabled: true, port: 5901, passwordFile: "/run/vnc/passwd" }, + }); + expect( + OpenClawSchema.safeParse({ desktop: { host: { enabled: true, port: 0 } } }).success, + ).toBe(false); + expect( + OpenClawSchema.safeParse({ desktop: { host: { enabled: true, passwordFile: "relative" } } }) + .success, + ).toBe(false); + expect( + OpenClawSchema.safeParse({ desktop: { host: { enabled: true, manageServer: true } } }) + .success, + ).toBe(false); + }); + + it("projects labels and help from each desktop field schema", () => { + const response = computeBaseConfigSchemaResponse({ generatedAt: "desktop-metadata" }); + for (const path of Object.keys(DESKTOP_FIELD_LABELS)) { + expect(response.uiHints[path]?.label, path).toBe(DESKTOP_FIELD_LABELS[path]); + expect(response.uiHints[path]?.help, path).toBe(DESKTOP_FIELD_HELP[path]); + } + }); +}); diff --git a/src/config/zod-schema.desktop.ts b/src/config/zod-schema.desktop.ts new file mode 100644 index 000000000000..9df0a4b3cf0b --- /dev/null +++ b/src/config/zod-schema.desktop.ts @@ -0,0 +1,68 @@ +// Defines gateway-host desktop config parsing and generated field metadata. +import path from "node:path"; +import { z } from "zod"; +import type { DesktopConfig } from "./types.desktop.js"; +import { configUiMetadata } from "./zod-schema.sensitive.js"; + +type ConfigSchemaShape = { + [Key in keyof T]-?: z.ZodType; +}; + +type DesktopHostConfig = NonNullable; + +const DesktopHostConfigShape = { + enabled: z.boolean().register(configUiMetadata, { + label: "Gateway Host Desktop (Labs)", + help: "Enables the experimental gateway-host desktop source. Restart the gateway after changing this setting.", + }), + port: z.number().int().min(1).max(65_535).optional().register(configUiMetadata, { + label: "Gateway Host VNC Port", + help: "Loopback RFB port of an already-running VNC server on the gateway host (default: 5900).", + }), + passwordFile: z + .string() + .trim() + .min(1) + .refine(path.isAbsolute, "Gateway host VNC passwordFile must be an absolute path") + .optional() + .register(configUiMetadata, { + label: "Gateway Host VNC Password File", + help: "Absolute path to the VNC password file. Omit on macOS to use account/ARD authentication after that support lands.", + }), +} satisfies ConfigSchemaShape; + +const DesktopHostConfigSchema = z + .object(DesktopHostConfigShape) + .strict() + .register(configUiMetadata, { + label: "Gateway Host Desktop", + help: "Connects OpenClaw to an already-running loopback-only VNC server on the gateway host.", + }); + +const DesktopConfigShape = { + host: DesktopHostConfigSchema.optional().register(configUiMetadata, { + label: "Gateway Host Desktop", + help: "Experimental gateway-host desktop observation backed by an already-running VNC server.", + }), +} satisfies ConfigSchemaShape; + +export const DesktopConfigSchema = z.object(DesktopConfigShape).strict().optional(); + +const DESKTOP_FIELD_SCHEMAS = { + "desktop.host": DesktopConfigShape.host, + "desktop.host.enabled": DesktopHostConfigShape.enabled, + "desktop.host.port": DesktopHostConfigShape.port, + "desktop.host.passwordFile": DesktopHostConfigShape.passwordFile, +}; + +function projectDesktopFieldMetadata(field: "label" | "help"): Record { + return Object.fromEntries( + Object.entries(DESKTOP_FIELD_SCHEMAS).flatMap(([fieldPath, schema]) => { + const value = configUiMetadata.get(schema)?.[field]; + return typeof value === "string" ? [[fieldPath, value]] : []; + }), + ); +} + +export const DESKTOP_FIELD_LABELS = projectDesktopFieldMetadata("label"); +export const DESKTOP_FIELD_HELP = projectDesktopFieldMetadata("help"); diff --git a/src/config/zod-schema.root-shape.ts b/src/config/zod-schema.root-shape.ts index 74d2b74c7a11..9ac1ebc187a9 100644 --- a/src/config/zod-schema.root-shape.ts +++ b/src/config/zod-schema.root-shape.ts @@ -15,6 +15,7 @@ import { SsrFPolicyConfigSchema, TtsConfigSchema, } from "./zod-schema.core.js"; +import { DesktopConfigSchema } from "./zod-schema.desktop.js"; import { GatewayConfigSchema } from "./zod-schema.gateway.js"; import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js"; import { BrowserSnapshotDefaultsSchema } from "./zod-schema.node-host.js"; @@ -424,6 +425,7 @@ export const OpenClawSchemaShape = { talk: TalkSchema.optional(), gateway: GatewayConfigSchema, cloudWorkers: CloudWorkersConfigSchema, + desktop: DesktopConfigSchema, memory: MemorySchema, mcp: McpConfigSchema, skills: z diff --git a/src/flows/doctor-health-contribution-runners.gateway.ts b/src/flows/doctor-health-contribution-runners.gateway.ts index 8d233b860588..c41175be1a3e 100644 --- a/src/flows/doctor-health-contribution-runners.gateway.ts +++ b/src/flows/doctor-health-contribution-runners.gateway.ts @@ -56,6 +56,11 @@ export async function runGatewayServicesHealth(ctx: DoctorHealthFlowContext): Pr await noteMacLaunchctlGatewayEnvOverrides(ctx.cfg); } +export async function runHostDesktopHealth(ctx: DoctorHealthFlowContext): Promise { + const { noteHostDesktopHealth } = await import("../commands/doctor-host-desktop.js"); + await noteHostDesktopHealth(ctx.cfg, { prompter: ctx.prompter }); +} + export async function runStartupChannelMaintenanceHealth( ctx: DoctorHealthFlowContext, ): Promise { diff --git a/src/flows/doctor-health-contributions-final.ts b/src/flows/doctor-health-contributions-final.ts index 0502e5a69391..a3b29b7ef033 100644 --- a/src/flows/doctor-health-contributions-final.ts +++ b/src/flows/doctor-health-contributions-final.ts @@ -11,6 +11,7 @@ import { runDevicePairingHealth, runGatewayDaemonHealth, runGatewayServicesHealth, + runHostDesktopHealth, runGitHubProjectHealth, runOpenAIOAuthTlsHealth, runSecurityHealth, @@ -56,6 +57,20 @@ export function resolveFinalDoctorHealthContributions(params: { ], run: runGatewayServicesHealth, }), + createDoctorHealthContribution({ + id: "doctor:host-desktop", + label: "Host desktop", + healthChecks: { + description: "Gateway-host desktop enablement, reachability, and RFB security state.", + defaultEnabled: false, + async detect(ctx) { + const { collectHostDesktopHealthFindings } = + await import("../commands/doctor-host-desktop.js"); + return collectHostDesktopHealthFindings(ctx.cfg); + }, + }, + run: runHostDesktopHealth, + }), createDoctorHealthContribution({ id: "doctor:default-account-routing", label: "Default account routing", diff --git a/src/gateway/desktop/host-guidance.ts b/src/gateway/desktop/host-guidance.ts new file mode 100644 index 000000000000..a37713f4669b --- /dev/null +++ b/src/gateway/desktop/host-guidance.ts @@ -0,0 +1,16 @@ +/** Platform-specific next steps for preparing a loopback-only host VNC server. */ +const HOST_DESKTOP_GUIDANCE = { + darwin: + "Enable System Settings -> General -> Sharing -> Screen Sharing, or run `sudo launchctl enable system/com.apple.screensharing && sudo launchctl kickstart -k system/com.apple.screensharing`.", + linux: + "Install TigerVNC with `apt install tigervnc-standalone-server` and run it loopback-only on port 5900, or run `x11vnc -display :0 -localhost -rfbport 5900 -forever -passwdfile `. gnome-remote-desktop uses unsupported VeNCrypt.", + win32: + "Install TightVNC with `SET_USEVNCAUTHENTICATION=1 SET_ALLOWLOOPBACK=1 ACCEPTHTTPCONNECTIONS=0` and listen on 127.0.0.1:5900. Locked or UAC sessions may render black.", +} as const; + +type HostDesktopPlatform = keyof typeof HOST_DESKTOP_GUIDANCE; + +/** Resolves guidance for supported gateway platforms, falling back to Linux-style setup. */ +export function getHostDesktopGuidance(platform: NodeJS.Platform): string { + return HOST_DESKTOP_GUIDANCE[platform as HostDesktopPlatform] ?? HOST_DESKTOP_GUIDANCE.linux; +} diff --git a/src/gateway/desktop/host-observe.integration.test.ts b/src/gateway/desktop/host-observe.integration.test.ts new file mode 100644 index 000000000000..227dc4636c8d --- /dev/null +++ b/src/gateway/desktop/host-observe.integration.test.ts @@ -0,0 +1,190 @@ +import http from "node:http"; +import net from "node:net"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { WebSocket, type RawData } from "ws"; +import { createHostDesktopService } from "./host-source.js"; +import { handleDesktopObserveUpgrade } from "./observe-bridge.js"; +import { createDesktopSessionRegistry } from "./session-registry.js"; + +const VERSION = Buffer.from("RFB 003.008\n", "ascii"); +const cleanups: Array<() => Promise> = []; + +afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); +}); + +class SocketReader { + private buffered = Buffer.alloc(0); + private readonly waiters = new Set<() => void>(); + + constructor(socket: net.Socket) { + socket.on("data", (chunk) => { + this.buffered = Buffer.concat([ + this.buffered, + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), + ]); + for (const waiter of this.waiters) { + waiter(); + } + this.waiters.clear(); + }); + } + + async readExactly(length: number): Promise { + while (this.buffered.length < length) { + await new Promise((resolve) => { + this.waiters.add(resolve); + }); + } + const value = this.buffered.subarray(0, length); + this.buffered = this.buffered.subarray(length); + return value; + } +} + +class WebSocketReader { + private readonly chunks: Buffer[] = []; + private readonly waiters: Array<(chunk: Buffer) => void> = []; + + constructor(ws: WebSocket) { + ws.on("message", (data: RawData) => { + const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer); + const waiter = this.waiters.shift(); + if (waiter) { + waiter(chunk); + } else { + this.chunks.push(chunk); + } + }); + } + + async next(): Promise { + const chunk = this.chunks.shift(); + return ( + chunk ?? + (await new Promise((resolve) => { + this.waiters.push(resolve); + })) + ); + } +} + +describe("gateway host desktop observe integration", () => { + it("pre-authenticates ARD, synthesizes None, and starts view-only filtering at ClientInit", async () => { + const peers = new Set(); + let connectionCount = 0; + let resolveObserverScript!: () => void; + let rejectObserverScript!: (error: Error) => void; + const observerScript = new Promise((resolve, reject) => { + resolveObserverScript = resolve; + rejectObserverScript = reject; + }); + const rfbServer = net.createServer((socket) => { + peers.add(socket); + socket.once("close", () => peers.delete(socket)); + connectionCount += 1; + const connectionIndex = connectionCount; + const reader = new SocketReader(socket); + void (async () => { + try { + socket.write(Buffer.from("RFB 003.889\n", "ascii")); + expect(await reader.readExactly(12)).toEqual(VERSION); + socket.write(Buffer.from([4, 30, 33, 36, 35])); + if (connectionIndex === 1) { + return; + } + + expect(await reader.readExactly(1)).toEqual(Buffer.from([30])); + const keyLength = 16; + const header = Buffer.alloc(4); + header.writeUInt16BE(5, 0); + header.writeUInt16BE(keyLength, 2); + const modulus = Buffer.alloc(keyLength); + modulus.writeUInt16BE(7919, keyLength - 2); + const serverPublic = Buffer.alloc(keyLength); + serverPublic.writeUInt16BE(6817, keyLength - 2); + socket.write(Buffer.concat([header, modulus, serverPublic])); + expect(await reader.readExactly(128 + keyLength)).toHaveLength(128 + keyLength); + socket.write(Buffer.alloc(4)); + + // Browser version/security bytes were consumed by the Gateway. ClientInit is first. + expect(await reader.readExactly(1)).toEqual(Buffer.from([1])); + socket.write(Buffer.from("server-init", "ascii")); + const framebufferRequest = Buffer.from([3, 1, 0, 0, 0, 0, 0, 64, 0, 64]); + expect(await reader.readExactly(framebufferRequest.length)).toEqual(framebufferRequest); + resolveObserverScript(); + } catch (error) { + rejectObserverScript(error instanceof Error ? error : new Error(String(error))); + } + })(); + }); + await new Promise((resolve, reject) => { + rfbServer.once("error", reject); + rfbServer.listen(0, "127.0.0.1", resolve); + }); + const rfbAddress = rfbServer.address(); + if (!rfbAddress || typeof rfbAddress === "string") { + throw new Error("expected RFB address"); + } + cleanups.push( + async () => + await new Promise((resolve) => { + for (const peer of peers) { + peer.destroy(); + } + rfbServer.close(() => resolve()); + }), + ); + + const registry = createDesktopSessionRegistry({ lingerMs: 10 }); + const service = createHostDesktopService({ + config: { enabled: true, port: rfbAddress.port }, + registry, + }); + cleanups.push(async () => registry.stopAll()); + const observed = await service.observe({ + control: false, + credentials: { username: "operator", password: "account-password" }, + }); + expect(observed.auth).toBe("ard-account"); + expect(observed.vncPassword).toBeUndefined(); + + const httpServer = http.createServer(); + httpServer.on("upgrade", (req, socket, head) => { + handleDesktopObserveUpgrade(req, socket, head, { registry }); + }); + await new Promise((resolve) => { + httpServer.listen(0, "127.0.0.1", resolve); + }); + const httpAddress = httpServer.address(); + if (!httpAddress || typeof httpAddress === "string") { + throw new Error("expected HTTP address"); + } + cleanups.push( + async () => + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }), + ); + + const ws = new WebSocket(`ws://127.0.0.1:${httpAddress.port}${observed.wsPath}`); + const browser = new WebSocketReader(ws); + cleanups.push(async () => ws.terminate()); + await new Promise((resolve, reject) => { + ws.once("open", resolve); + ws.once("error", reject); + }); + expect(await browser.next()).toEqual(VERSION); + // Coalesce the synthetic handshake replies with exclusive ClientInit. + ws.send(Buffer.concat([VERSION, Buffer.from([1, 0])])); + expect(await browser.next()).toEqual(Buffer.from([1, 1])); + expect(await browser.next()).toEqual(Buffer.alloc(4)); + expect(await browser.next()).toEqual(Buffer.from("server-init", "ascii")); + + const keyEvent = Buffer.from([4, 1, 0, 0, 0, 0, 0, 65]); + const framebufferRequest = Buffer.from([3, 1, 0, 0, 0, 0, 0, 64, 0, 64]); + ws.send(Buffer.concat([keyEvent, framebufferRequest])); + await expect(observerScript).resolves.toBeUndefined(); + await vi.waitFor(() => expect(connectionCount).toBe(2)); + }); +}); diff --git a/src/gateway/desktop/host-source-errors.ts b/src/gateway/desktop/host-source-errors.ts new file mode 100644 index 000000000000..72cb6830a345 --- /dev/null +++ b/src/gateway/desktop/host-source-errors.ts @@ -0,0 +1,15 @@ +export class HostDesktopCredentialsRequiredError extends Error { + readonly auth = "ard-account" as const; + readonly detailCode = "DESKTOP_CREDENTIALS_REQUIRED" as const; + + constructor() { + super("macOS account credentials are required to observe Screen Sharing"); + this.name = "HostDesktopCredentialsRequiredError"; + } +} + +export function isHostDesktopCredentialsRequiredError( + error: unknown, +): error is HostDesktopCredentialsRequiredError { + return error instanceof HostDesktopCredentialsRequiredError; +} diff --git a/src/gateway/desktop/host-source.test.ts b/src/gateway/desktop/host-source.test.ts new file mode 100644 index 000000000000..78e6965f5fd0 --- /dev/null +++ b/src/gateway/desktop/host-source.test.ts @@ -0,0 +1,167 @@ +import fs from "node:fs/promises"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { isSecretValueRegisteredForRedaction } from "../../logging/secret-redaction-registry.js"; +import { + createHostDesktopService, + createHostDesktopSource, + inspectHostDesktop, +} from "./host-source.js"; +import { createDesktopSessionRegistry } from "./session-registry.js"; + +const cleanups: Array<() => Promise> = []; + +afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); +}); + +async function listenRfb(params: { banner?: string; securityTypes?: number[] }) { + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + socket.write(Buffer.from(params.banner ?? "RFB 003.008\n", "ascii")); + if (params.securityTypes) { + socket.once("data", () => { + socket.write(Buffer.from([params.securityTypes!.length, ...params.securityTypes!])); + }); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("expected RFB server address"); + } + cleanups.push( + async () => + await new Promise((resolve) => { + for (const socket of sockets) { + socket.destroy(); + } + server.close(() => resolve()); + }), + ); + return address.port; +} + +async function unusedPort(): Promise { + const server = net.createServer(); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("expected TCP address"); + } + await new Promise((resolve) => { + server.close(() => resolve()); + }); + return address.port; +} + +describe("gateway host desktop source", () => { + it("refuses an unauthenticated VNC server", async () => { + const port = await listenRfb({ securityTypes: [1] }); + const source = createHostDesktopSource({ config: { enabled: true, port } }); + await expect(source.acquire()).rejects.toThrow( + `refusing unauthenticated VNC server on 127.0.0.1:${port}`, + ); + }); + + it("returns a loopback attachment and redacted password-file value for VncAuth", async () => { + const port = await listenRfb({ securityTypes: [2] }); + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-host-desktop-")); + const passwordFile = path.join(root, "passwd"); + const password = "desktop-secret"; + await fs.writeFile(passwordFile, `${password}\n`); + cleanups.push(async () => fs.rm(root, { recursive: true, force: true })); + + const source = createHostDesktopSource({ + config: { enabled: true, port, passwordFile }, + }); + await expect(source.acquire()).resolves.toEqual({ + attachment: { kind: "tcp", host: "127.0.0.1", port }, + auth: "vnc-password", + vncPassword: password, + }); + expect(isSecretValueRegisteredForRedaction(password)).toBe(true); + }); + + it("keeps the VncAuth credential prompt path when passwordFile is omitted", async () => { + const port = await listenRfb({ securityTypes: [2] }); + const source = createHostDesktopSource({ config: { enabled: true, port } }); + await expect(source.acquire()).resolves.toEqual({ + attachment: { kind: "tcp", host: "127.0.0.1", port }, + auth: "vnc-password", + }); + }); + + it("attaches ARD and keeps account credentials only in the observer token", async () => { + const port = await listenRfb({ banner: "RFB 003.889\n", securityTypes: [30] }); + const source = createHostDesktopSource({ + config: { enabled: true, port }, + platform: "darwin", + }); + await expect(source.acquire()).resolves.toEqual({ + attachment: { kind: "tcp", host: "127.0.0.1", port }, + auth: "ard-account", + }); + + const registry = createDesktopSessionRegistry(); + const service = createHostDesktopService({ + config: { enabled: true, port }, + platform: "darwin", + registry, + }); + cleanups.push(async () => registry.stopAll()); + await expect(service.observe({ control: false })).rejects.toThrow( + "macOS account credentials are required", + ); + const password = "mac-account-password"; + const observed = await service.observe({ + control: false, + credentials: { username: "operator", password }, + }); + expect(observed).toMatchObject({ auth: "ard-account", control: false }); + expect(observed).not.toHaveProperty("vncPassword"); + expect(observed.wsPath).toMatch(/^\/desktop\/observe\?token=[a-f0-9]{48}$/u); + expect(observed.wsPath).not.toContain("operator"); + expect(observed.wsPath).not.toContain(password); + expect(isSecretValueRegisteredForRedaction(password)).toBe(true); + + await expect( + inspectHostDesktop({ config: { enabled: true, port }, platform: "darwin" }), + ).resolves.toMatchObject({ + status: { state: "attached", security: "ARD" }, + detail: `attached (127.0.0.1:${port}, security: ARD)`, + }); + }); + + it("still refuses VeNCrypt", async () => { + const port = await listenRfb({ securityTypes: [19] }); + const source = createHostDesktopSource({ config: { enabled: true, port } }); + await expect(source.acquire()).rejects.toThrow("VeNCrypt is not supported"); + }); + + it("reports a non-VNC occupant and the port config next step", async () => { + const port = await listenRfb({ banner: "HTTP/1.1 200" }); + const source = createHostDesktopSource({ config: { enabled: true, port } }); + await expect(source.acquire()).rejects.toThrow( + `desktop.host.port ${port} is occupied by a non-VNC service; configure desktop.host.port`, + ); + }); + + it("reports unreachable Linux setup guidance", async () => { + const port = await unusedPort(); + const source = createHostDesktopSource({ + config: { enabled: true, port }, + platform: "linux", + }); + await expect(source.acquire()).rejects.toThrow("apt install tigervnc-standalone-server"); + }); +}); diff --git a/src/gateway/desktop/host-source.ts b/src/gateway/desktop/host-source.ts new file mode 100644 index 000000000000..8209d37b8053 --- /dev/null +++ b/src/gateway/desktop/host-source.ts @@ -0,0 +1,249 @@ +import fs from "node:fs/promises"; +import type { DesktopHostConfig } from "../../config/types.desktop.js"; +import { registerSecretValueForRedaction } from "../../logging/secret-redaction-registry.js"; +import type { RfbAttachment } from "./attachment.js"; +import { getHostDesktopGuidance } from "./host-guidance.js"; +import { HostDesktopCredentialsRequiredError } from "./host-source-errors.js"; +import { mintDesktopObserverToken } from "./observe-bridge.js"; +import { classifyRfbSecurity, probeRfbServer, type RfbProbeResult } from "./rfb-probe.js"; +import type { DesktopSessionRegistry } from "./session-registry.js"; + +const DEFAULT_HOST_DESKTOP_PORT = 5900; +const HOST_DESKTOP_PROBE_TIMEOUT_MS = 1_500; + +export type HostDesktopAcquireResult = { + attachment: RfbAttachment; + auth: "vnc-password" | "ard-account"; + vncPassword?: string; +}; + +export type HostDesktopStatus = { + enabled: boolean; + state: "attached" | "unavailable" | "disabled"; + port: number; + security?: string; +}; + +export type HostDesktopInspection = { + status: HostDesktopStatus; + detail: string; + unavailableReason?: "not-listening" | "not-rfb" | "unsupported"; +}; + +function nonRfbError(port: number): string { + return `desktop.host.port ${port} is occupied by a non-VNC service; configure desktop.host.port for the loopback VNC server, then restart the gateway`; +} + +function unavailableError(port: number, platform: NodeJS.Platform): string { + return `gateway host desktop is unavailable at 127.0.0.1:${port}. ${getHostDesktopGuidance(platform)}`; +} + +function securityLabel(probe: Extract): string { + const auth = classifyRfbSecurity(probe.securityTypes); + if (auth === "vnc-password") { + return "VncAuth"; + } + if (auth === "ard-account") { + return "ARD"; + } + if (auth === "none") { + return "None"; + } + return probe.securityTypes.includes(19) ? "VeNCrypt" : "unsupported"; +} + +/** Probes the configured host desktop without reading or exposing password material. */ +export async function inspectHostDesktop(params: { + config?: DesktopHostConfig; + platform?: NodeJS.Platform; +}): Promise { + const port = params.config?.port ?? DEFAULT_HOST_DESKTOP_PORT; + if (params.config?.enabled !== true) { + return { + status: { enabled: false, state: "disabled", port }, + detail: + "disabled; enable the Desktop lab with desktop.host.enabled=true, then restart the gateway", + }; + } + const platform = params.platform ?? process.platform; + const probe = await probeRfbServer({ + host: "127.0.0.1", + port, + timeoutMs: HOST_DESKTOP_PROBE_TIMEOUT_MS, + }); + if (probe.kind === "unreachable" || probe.kind === "timeout") { + return { + status: { enabled: true, state: "unavailable", port }, + detail: unavailableError(port, platform), + unavailableReason: "not-listening", + }; + } + if (probe.kind === "not-rfb") { + return { + status: { enabled: true, state: "unavailable", port }, + detail: nonRfbError(port), + unavailableReason: "not-rfb", + }; + } + const security = securityLabel(probe); + const auth = classifyRfbSecurity(probe.securityTypes); + if (auth === "vnc-password" || auth === "ard-account") { + return { + status: { enabled: true, state: "attached", port, security }, + detail: `attached (127.0.0.1:${port}, security: ${security})`, + }; + } + const detail = + auth === "none" + ? `unavailable: unauthenticated VNC server at 127.0.0.1:${port}; require a password-protected VncAuth server, then retry` + : `unavailable: ${security} security is not supported; configure a VncAuth server and desktop.host.passwordFile, then retry`; + return { + status: { enabled: true, state: "unavailable", port, security }, + detail, + unavailableReason: "unsupported", + }; +} + +/** Creates the host acquisition hook consumed by the source-agnostic desktop registry. */ +export function createHostDesktopSource(params: { + config: DesktopHostConfig; + platform?: NodeJS.Platform; +}) { + const port = params.config.port ?? DEFAULT_HOST_DESKTOP_PORT; + const platform = params.platform ?? process.platform; + + const acquire = async (): Promise => { + const probe = await probeRfbServer({ + host: "127.0.0.1", + port, + timeoutMs: HOST_DESKTOP_PROBE_TIMEOUT_MS, + }); + if (probe.kind === "unreachable" || probe.kind === "timeout") { + throw new Error(unavailableError(port, platform)); + } + if (probe.kind === "not-rfb") { + throw new Error(nonRfbError(port)); + } + const security = classifyRfbSecurity(probe.securityTypes); + if (security === "none") { + throw new Error( + `refusing unauthenticated VNC server on 127.0.0.1:${port}; require a password-protected VncAuth server, then retry`, + ); + } + if (security === "unsupported") { + const name = probe.securityTypes.includes(19) ? "VeNCrypt" : "the offered VNC security"; + throw new Error( + `${name} is not supported; configure a VncAuth server and desktop.host.passwordFile, then retry`, + ); + } + + let vncPassword: string | undefined; + if (params.config.passwordFile) { + try { + vncPassword = (await fs.readFile(params.config.passwordFile, "utf8")).replace( + /[\r\n]+$/u, + "", + ); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `could not read desktop.host.passwordFile ${params.config.passwordFile}: ${reason}; fix the absolute path or remove desktop.host.passwordFile so the UI can prompt`, + { cause: error }, + ); + } + if (!vncPassword) { + throw new Error( + "desktop.host.passwordFile is empty; write the VNC password or remove desktop.host.passwordFile so the UI can prompt", + ); + } + registerSecretValueForRedaction(vncPassword); + } + return { + attachment: { kind: "tcp", host: "127.0.0.1", port }, + auth: security, + ...(vncPassword ? { vncPassword } : {}), + }; + }; + + return { acquire }; +} + +export type HostDesktopService = { + observe(params: { + control: boolean; + credentials?: { username?: string; password?: string }; + }): Promise<{ + transport: "rfb"; + wsPath: string; + expiresAtMs: number; + control: boolean; + auth: "vnc-password" | "ard-account"; + vncPassword?: string; + }>; + status(): Promise; +}; + +/** Combines host acquisition, registry ownership, and observer-token minting. */ +export function createHostDesktopService(params: { + config: DesktopHostConfig; + registry: DesktopSessionRegistry; + platform?: NodeJS.Platform; +}): HostDesktopService { + const source = createHostDesktopSource({ + config: params.config, + ...(params.platform ? { platform: params.platform } : {}), + }); + return { + async observe(observeParams) { + const acquired = await params.registry.acquire({ + sourceKey: "host", + ownerEpoch: 0, + start: source.acquire, + }); + const auth = acquired.auth; + if (!auth) { + throw new Error("gateway host desktop authentication state is unavailable; retry observe"); + } + let preauth: + | { + auth: "ard-account"; + credentials: { username: string; password: string }; + } + | undefined; + if (auth === "ard-account") { + const username = observeParams.credentials?.username?.trim() ?? ""; + const password = observeParams.credentials?.password ?? ""; + if (!username || !password) { + throw new HostDesktopCredentialsRequiredError(); + } + registerSecretValueForRedaction(password); + preauth = { auth: "ard-account", credentials: { username, password } }; + } + const minted = mintDesktopObserverToken({ + sourceKey: "host", + ownerEpoch: 0, + control: observeParams.control, + attachment: acquired.attachment, + ...(preauth ? { preauth } : {}), + }); + return { + transport: "rfb", + wsPath: `/desktop/observe?token=${minted.token}`, + expiresAtMs: minted.expiresAtMs, + control: observeParams.control, + auth, + ...(auth === "vnc-password" && acquired.vncPassword + ? { vncPassword: acquired.vncPassword } + : {}), + }; + }, + async status() { + return ( + await inspectHostDesktop({ + config: params.config, + ...(params.platform ? { platform: params.platform } : {}), + }) + ).status; + }, + }; +} diff --git a/src/gateway/desktop/observe-bridge.test.ts b/src/gateway/desktop/observe-bridge.test.ts index a20ddc1a3818..af5157896d36 100644 --- a/src/gateway/desktop/observe-bridge.test.ts +++ b/src/gateway/desktop/observe-bridge.test.ts @@ -10,6 +10,7 @@ import { handleDesktopObserveUpgrade, mintDesktopObserverToken, } from "./observe-bridge.js"; +import type { RfbPreauthDescriptor } from "./rfb-preauth.js"; const cleanup: Array<() => Promise> = []; @@ -33,7 +34,11 @@ describe("worker desktop observer tokens", () => { }); async function createProxyHarness( - params: { control?: boolean; getBufferedAmount?: () => number } = {}, + params: { + control?: boolean; + getBufferedAmount?: () => number; + preauth?: RfbPreauthDescriptor; + } = {}, ) { const root = await fs.mkdtemp(path.join(await fs.realpath(os.tmpdir()), "desktop-observe-")); const localSocketPath = path.join(root, "desktop.sock"); @@ -86,6 +91,7 @@ async function createProxyHarness( ownerEpoch: 2, control: params.control ?? false, attachment: { kind: "unix-socket", socketPath: localSocketPath }, + ...(params.preauth ? { preauth: params.preauth } : {}), }); const ws = new WebSocket( `ws://127.0.0.1:${address.port}${DESKTOP_OBSERVE_PATH}?token=${minted.token}`, @@ -129,6 +135,21 @@ async function expectUnauthorizedObserver(url: string): Promise { } describe("worker desktop observer proxy", () => { + it("clears the credential-bearing token timer when the token is consumed", async () => { + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout"); + await createProxyHarness({ + preauth: { + auth: "ard-account", + credentials: { username: "operator", password: "memory-only-password" }, + }, + }); + const expiryCallIndex = setTimeoutSpy.mock.calls.findIndex(([, delay]) => delay === 60_000); + expect(expiryCallIndex).toBeGreaterThanOrEqual(0); + const expiryTimer = setTimeoutSpy.mock.results[expiryCallIndex]?.value; + expect(clearTimeoutSpy).toHaveBeenCalledWith(expiryTimer); + }); + it("rejects consumed, expired, and unknown tokens", async () => { const harness = await createProxyHarness(); await expectUnauthorizedObserver(harness.observerUrl); diff --git a/src/gateway/desktop/observe-bridge.ts b/src/gateway/desktop/observe-bridge.ts index 5d65c9e676a7..45fa6fbbe475 100644 --- a/src/gateway/desktop/observe-bridge.ts +++ b/src/gateway/desktop/observe-bridge.ts @@ -3,6 +3,13 @@ import type { IncomingMessage } from "node:http"; import type { Duplex } from "node:stream"; import { WebSocket, WebSocketServer, type RawData } from "ws"; import { connectRfbAttachment, type RfbAttachment } from "./attachment.js"; +import { + preauthenticateRfb, + RfbPreauthBuffer, + type RfbPreauthDescriptor, + type RfbPreauthPeer, + RfbPreauthTimeoutError, +} from "./rfb-preauth.js"; import { createRfbClientMessageFilter } from "./rfb-view-only-filter.js"; import type { DesktopSessionRegistry } from "./session-registry.js"; @@ -18,16 +25,27 @@ type DesktopObserverTokenEntry = { ownerEpoch: number; control: boolean; attachment: RfbAttachment; + preauth?: RfbPreauthDescriptor; expiresAt: number; }; const observerTokens = new Map(); +const observerTokenExpiryTimers = new Map>(); const desktopObserverWss = new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES }); +function deleteDesktopObserverToken(token: string): void { + observerTokens.delete(token); + const expiryTimer = observerTokenExpiryTimers.get(token); + if (expiryTimer) { + clearTimeout(expiryTimer); + observerTokenExpiryTimers.delete(token); + } +} + function pruneDesktopObserverTokens(nowMs: number): void { for (const [token, entry] of observerTokens) { if (entry.expiresAt <= nowMs) { - observerTokens.delete(token); + deleteDesktopObserverToken(token); } } } @@ -37,19 +55,28 @@ export function mintDesktopObserverToken(params: { ownerEpoch: number; control: boolean; attachment: RfbAttachment; + preauth?: RfbPreauthDescriptor; nowMs?: number; }): { token: string; expiresAtMs: number } { const nowMs = params.nowMs ?? Date.now(); pruneDesktopObserverTokens(nowMs); const token = crypto.randomBytes(24).toString("hex"); const expiresAtMs = nowMs + TOKEN_TTL_MS; - observerTokens.set(token, { + const entry: DesktopObserverTokenEntry = { sourceKey: params.sourceKey, ownerEpoch: params.ownerEpoch, control: params.control, attachment: params.attachment, + ...(params.preauth ? { preauth: params.preauth } : {}), expiresAt: expiresAtMs, - }); + }; + observerTokens.set(token, entry); + const expiryTimer = setTimeout(() => { + observerTokens.delete(token); + observerTokenExpiryTimers.delete(token); + }, TOKEN_TTL_MS); + expiryTimer.unref?.(); + observerTokenExpiryTimers.set(token, expiryTimer); return { token, expiresAtMs }; } @@ -66,7 +93,7 @@ function consumeDesktopObserverToken( if (!entry) { return undefined; } - observerTokens.delete(normalized); + deleteDesktopObserverToken(normalized); return entry.expiresAt > nowMs ? entry : undefined; } @@ -85,6 +112,66 @@ function rawDataBuffer(data: RawData): Buffer { return Buffer.from(data); } +class WebSocketPreauthPeer implements RfbPreauthPeer { + private readonly reader = new RfbPreauthBuffer(); + private readonly onMessage = (data: RawData, isBinary: boolean) => { + if (!isBinary) { + this.reader.fail(new Error("RFB browser sent a non-binary handshake frame")); + } else { + this.reader.push(rawDataBuffer(data)); + } + }; + private readonly onClose = () => { + this.reader.fail(new Error("RFB browser closed during authentication negotiation")); + }; + private readonly onError = () => { + this.reader.fail(new Error("RFB browser failed during authentication negotiation")); + }; + + constructor(private readonly ws: WebSocket) { + ws.on("message", this.onMessage); + ws.once("close", this.onClose); + ws.once("error", this.onError); + } + + async readExactly(length: number, signal: AbortSignal): Promise { + return await this.reader.readExactly(length, signal); + } + + async write(buffer: Buffer, signal: AbortSignal): Promise { + if (signal.aborted) { + throw signal.reason; + } + await new Promise((resolve, reject) => { + const cleanup = () => signal.removeEventListener("abort", onAbort); + const onAbort = () => { + cleanup(); + reject( + signal.reason instanceof Error + ? signal.reason + : new Error("RFB authentication negotiation aborted"), + ); + }; + signal.addEventListener("abort", onAbort, { once: true }); + this.ws.send(buffer, { binary: true }, (error) => { + cleanup(); + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } + + detach(): Buffer { + this.ws.off("message", this.onMessage); + this.ws.off("close", this.onClose); + this.ws.off("error", this.onError); + return this.reader.takeBuffered(); + } +} + /** Upgrades one authenticated observer token into a raw bidirectional RFB stream. */ export function handleDesktopObserveUpgrade( req: IncomingMessage, @@ -117,8 +204,8 @@ export function handleDesktopObserveUpgrade( return; } const desktopSocket = connectRfbAttachment(entry.attachment); - const clientMessageFilter = entry.control ? undefined : createRfbClientMessageFilter(); let closed = false; + let negotiating = Boolean(entry.preauth); let resumeTimer: ReturnType | undefined; const closeBoth = (code: number, reason: string) => { @@ -135,47 +222,93 @@ export function handleDesktopObserveUpgrade( } }; - ws.on("message", (data, isBinary) => { - if (!isBinary || closed) { - return; + const startSplice = (browserRemainder: Buffer = Buffer.alloc(0), preauthenticated = false) => { + const clientMessageFilter = entry.control + ? undefined + : createRfbClientMessageFilter({ + startPhase: preauthenticated ? "clientInit" : "version", + }); + const forwardClientChunk = (chunk: Buffer) => { + if (!clientMessageFilter) { + desktopSocket.write(chunk); + return; + } + const result = clientMessageFilter.filter(chunk); + if ("error" in result) { + closeBoth(1008, "invalid view-only RFB stream"); + return; + } + if (result.forward.length > 0) { + desktopSocket.write(result.forward); + } + }; + ws.on("message", (data, isBinary) => { + if (!isBinary || closed) { + return; + } + forwardClientChunk(rawDataBuffer(data)); + }); + desktopSocket.on("data", (chunk) => { + if (closed || ws.readyState !== WebSocket.OPEN) { + return; + } + ws.send(chunk, { binary: true }); + const bufferedAmount = () => deps.getBufferedAmount?.(ws) ?? ws.bufferedAmount; + if (bufferedAmount() <= PAUSE_BUFFERED_BYTES || resumeTimer) { + return; + } + desktopSocket.pause(); + resumeTimer = setInterval(() => { + if (bufferedAmount() <= PAUSE_BUFFERED_BYTES) { + clearInterval(resumeTimer); + resumeTimer = undefined; + desktopSocket.resume(); + } + }, RESUME_CHECK_MS); + resumeTimer.unref?.(); + }); + if (browserRemainder.length > 0) { + forwardClientChunk(browserRemainder); } - const chunk = rawDataBuffer(data); - if (!clientMessageFilter) { - desktopSocket.write(chunk); - return; - } - const result = clientMessageFilter.filter(chunk); - if ("error" in result) { - closeBoth(1008, "invalid view-only RFB stream"); - return; - } - if (result.forward.length > 0) { - desktopSocket.write(result.forward); - } - }); + }; + ws.once("close", () => closeBoth(1000, "desktop observer closed")); ws.once("error", () => closeBoth(1011, "desktop observer failed")); - desktopSocket.on("data", (chunk) => { - if (closed || ws.readyState !== WebSocket.OPEN) { - return; - } - ws.send(chunk, { binary: true }); - const bufferedAmount = () => deps.getBufferedAmount?.(ws) ?? ws.bufferedAmount; - if (bufferedAmount() <= PAUSE_BUFFERED_BYTES || resumeTimer) { - return; - } - desktopSocket.pause(); - resumeTimer = setInterval(() => { - if (bufferedAmount() <= PAUSE_BUFFERED_BYTES) { - clearInterval(resumeTimer); - resumeTimer = undefined; - desktopSocket.resume(); - } - }, RESUME_CHECK_MS); - resumeTimer.unref?.(); - }); desktopSocket.once("close", () => closeBoth(1000, "desktop stream closed")); - desktopSocket.once("error", () => closeBoth(1011, "desktop stream failed")); + desktopSocket.once("error", () => + closeBoth( + negotiating ? 1008 : 1011, + negotiating ? "desktop authentication failed" : "desktop stream failed", + ), + ); + + if (!entry.preauth) { + startSplice(); + return; + } + + const preauth = entry.preauth; + const browser = new WebSocketPreauthPeer(ws); + void (async () => { + try { + await preauthenticateRfb({ server: desktopSocket, browser, preauth }); + const remainder = browser.detach(); + entry.preauth = undefined; + negotiating = false; + if (!closed) { + startSplice(remainder, true); + } + } catch (error) { + browser.detach(); + entry.preauth = undefined; + closeBoth( + 1008, + error instanceof RfbPreauthTimeoutError + ? "desktop authentication timed out" + : `desktop ${preauth.auth === "ard-account" ? "ARD" : "VNC"} authentication failed`, + ); + } + })(); }); return true; } diff --git a/src/gateway/desktop/rfb-preauth.test.ts b/src/gateway/desktop/rfb-preauth.test.ts new file mode 100644 index 000000000000..e8a7223cc18c --- /dev/null +++ b/src/gateway/desktop/rfb-preauth.test.ts @@ -0,0 +1,295 @@ +import { createDecipheriv, createHash } from "node:crypto"; +import { type Duplex, duplexPair } from "node:stream"; +import { describe, expect, it } from "vitest"; +import { + preauthenticateRfb, + type RfbPreauthDescriptor, + type RfbPreauthPeer, +} from "./rfb-preauth.js"; + +const VERSION_3_8 = Buffer.from("RFB 003.008\n", "ascii"); + +class ScriptedPeer implements RfbPreauthPeer { + private buffered = Buffer.alloc(0); + private failure: Error | undefined; + private readonly waiters = new Set<() => void>(); + + constructor(readonly stream: Duplex) { + stream.on("data", (chunk: Buffer) => { + this.buffered = Buffer.concat([this.buffered, chunk]); + this.wake(); + }); + stream.once("error", (error) => { + this.failure = error; + this.wake(); + }); + stream.once("close", () => { + this.failure = new Error("scripted peer closed"); + this.wake(); + }); + } + + private wake(): void { + for (const waiter of this.waiters) { + waiter(); + } + this.waiters.clear(); + } + + async readExactly(length: number, signal?: AbortSignal): Promise { + while (this.buffered.length < length) { + if (this.failure) { + throw this.failure; + } + await new Promise((resolve, reject) => { + const onAbort = () => { + this.waiters.delete(onWake); + reject( + signal?.reason instanceof Error + ? signal.reason + : new Error("scripted RFB negotiation aborted"), + ); + }; + const onWake = () => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }; + this.waiters.add(onWake); + signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + const value = this.buffered.subarray(0, length); + this.buffered = this.buffered.subarray(length); + return value; + } + + async write(buffer: Buffer): Promise { + await new Promise((resolve, reject) => { + this.stream.write(buffer, (error) => (error ? reject(error) : resolve())); + }); + } +} + +function bigIntBuffer(value: bigint, length: number): Buffer { + const hex = value.toString(16); + const bytes = Buffer.from(hex.length % 2 === 0 ? hex : `0${hex}`, "hex"); + const result = Buffer.alloc(length); + bytes.copy(result, length - bytes.length); + return result; +} + +function bufferBigInt(value: Buffer): bigint { + return BigInt(`0x${value.toString("hex")}`); +} + +function modPow(base: bigint, exponent: bigint, modulus: bigint): bigint { + let result = 1n; + let factor = base % modulus; + let power = exponent; + while (power > 0n) { + if ((power & 1n) === 1n) { + result = (result * factor) % modulus; + } + factor = (factor * factor) % modulus; + power >>= 1n; + } + return result; +} + +async function completeSyntheticBrowserHandshake(browser: ScriptedPeer): Promise { + expect(await browser.readExactly(12)).toEqual(VERSION_3_8); + await browser.write(VERSION_3_8); + expect(await browser.readExactly(2)).toEqual(Buffer.from([1, 1])); + await browser.write(Buffer.from([1])); + expect(await browser.readExactly(4)).toEqual(Buffer.alloc(4)); +} + +async function runPreauth(params: { + preauth: RfbPreauthDescriptor; + serverScript: (server: ScriptedPeer) => Promise; +}): Promise { + const [gatewayServer, fakeServerStream] = duplexPair(); + const [gatewayBrowserStream, fakeBrowserStream] = duplexPair(); + const gatewayBrowser = new ScriptedPeer(gatewayBrowserStream); + const fakeServer = new ScriptedPeer(fakeServerStream); + const fakeBrowser = new ScriptedPeer(fakeBrowserStream); + try { + await Promise.all([ + preauthenticateRfb({ + server: gatewayServer, + browser: gatewayBrowser, + preauth: params.preauth, + }), + params.serverScript(fakeServer), + completeSyntheticBrowserHandshake(fakeBrowser), + ]); + } finally { + gatewayServer.destroy(); + fakeServerStream.destroy(); + gatewayBrowserStream.destroy(); + fakeBrowserStream.destroy(); + } +} + +async function writeArdOffer(server: ScriptedPeer, keyLength: number): Promise { + await server.write(Buffer.from("RFB 003.889\n", "ascii")); + expect(await server.readExactly(12)).toEqual(VERSION_3_8); + await server.write(Buffer.from([4, 30, 33, 36, 35])); + expect(await server.readExactly(1)).toEqual(Buffer.from([30])); + const generator = 5n; + const modulus = 7919n; + const serverPrivate = 7n; + const serverPublic = modPow(generator, serverPrivate, modulus); + const header = Buffer.alloc(4); + header.writeUInt16BE(Number(generator), 0); + header.writeUInt16BE(keyLength, 2); + await server.write( + Buffer.concat([ + header, + bigIntBuffer(modulus, keyLength), + bigIntBuffer(serverPublic, keyLength), + ]), + ); +} + +describe("RFB server-side pre-authentication", () => { + it.each([16, 32])( + "negotiates ARD framing and encrypted credentials at %i bytes", + async (keyLength) => { + const username = "screen-user"; + const password = "screen-password"; + await runPreauth({ + preauth: { auth: "ard-account", credentials: { username, password } }, + serverScript: async (server) => { + await writeArdOffer(server, keyLength); + const response = await server.readExactly(128 + keyLength); + expect(response).toHaveLength(128 + keyLength); + + const modulus = 7919n; + const serverPrivate = 7n; + const clientPublic = bufferBigInt(response.subarray(128)); + const shared = modPow(clientPublic, serverPrivate, modulus); + const key = createHash("md5").update(bigIntBuffer(shared, keyLength)).digest(); + const decipher = createDecipheriv("aes-128-ecb", key, null); + decipher.setAutoPadding(false); + const plaintext = Buffer.concat([ + decipher.update(response.subarray(0, 128)), + decipher.final(), + ]); + expect(plaintext.subarray(0, username.length).toString("utf8")).toBe(username); + expect(plaintext[username.length]).toBe(0); + expect(plaintext.subarray(64, 64 + password.length).toString("utf8")).toBe(password); + expect(plaintext[64 + password.length]).toBe(0); + await server.write(Buffer.alloc(4)); + }, + }); + }, + ); + + it.each([0, 1025])("rejects malformed ARD key length %i", async (keyLength) => { + const [gatewayServer, fakeServerStream] = duplexPair(); + const [gatewayBrowserStream, fakeBrowserStream] = duplexPair(); + const fakeServer = new ScriptedPeer(fakeServerStream); + const preauth = preauthenticateRfb({ + server: gatewayServer, + browser: new ScriptedPeer(gatewayBrowserStream), + preauth: { + auth: "ard-account", + credentials: { username: "operator", password: "password" }, + }, + }); + try { + await fakeServer.write(VERSION_3_8); + expect(await fakeServer.readExactly(12)).toEqual(VERSION_3_8); + await fakeServer.write(Buffer.from([1, 30])); + expect(await fakeServer.readExactly(1)).toEqual(Buffer.from([30])); + const header = Buffer.alloc(4); + header.writeUInt16BE(5, 0); + header.writeUInt16BE(keyLength, 2); + await fakeServer.write(header); + await expect(preauth).rejects.toThrow(`invalid ARD key length ${keyLength}`); + } finally { + gatewayServer.destroy(); + fakeServerStream.destroy(); + gatewayBrowserStream.destroy(); + fakeBrowserStream.destroy(); + } + }); + + it("rejects zero ARD Diffie-Hellman parameters", async () => { + const [gatewayServer, fakeServerStream] = duplexPair(); + const [gatewayBrowserStream, fakeBrowserStream] = duplexPair(); + const fakeServer = new ScriptedPeer(fakeServerStream); + const preauth = preauthenticateRfb({ + server: gatewayServer, + browser: new ScriptedPeer(gatewayBrowserStream), + preauth: { + auth: "ard-account", + credentials: { username: "operator", password: "password" }, + }, + }); + try { + await fakeServer.write(VERSION_3_8); + expect(await fakeServer.readExactly(12)).toEqual(VERSION_3_8); + await fakeServer.write(Buffer.from([1, 30])); + expect(await fakeServer.readExactly(1)).toEqual(Buffer.from([30])); + const header = Buffer.alloc(4); + header.writeUInt16BE(5, 0); + header.writeUInt16BE(8, 2); + await fakeServer.write(Buffer.concat([header, Buffer.alloc(16)])); + await expect(preauth).rejects.toThrow("invalid ARD Diffie-Hellman parameters"); + } finally { + gatewayServer.destroy(); + fakeServerStream.destroy(); + gatewayBrowserStream.destroy(); + fakeBrowserStream.destroy(); + } + }); + + it("surfaces the ARD server SecurityResult reason", async () => { + const reason = Buffer.from("account rejected", "utf8"); + const [gatewayServer, fakeServerStream] = duplexPair(); + const [gatewayBrowserStream, fakeBrowserStream] = duplexPair(); + const fakeServer = new ScriptedPeer(fakeServerStream); + const preauth = preauthenticateRfb({ + server: gatewayServer, + browser: new ScriptedPeer(gatewayBrowserStream), + preauth: { + auth: "ard-account", + credentials: { username: "operator", password: "password" }, + }, + }); + try { + await writeArdOffer(fakeServer, 16); + await fakeServer.readExactly(144); + const status = Buffer.alloc(8); + status.writeUInt32BE(1, 0); + status.writeUInt32BE(reason.length, 4); + await fakeServer.write(Buffer.concat([status, reason])); + await expect(preauth).rejects.toThrow("RFB authentication failed: account rejected"); + } finally { + gatewayServer.destroy(); + fakeServerStream.destroy(); + gatewayBrowserStream.destroy(); + fakeBrowserStream.destroy(); + } + }); + + it("matches the VncAuth bit-reversed DES challenge vector", async () => { + const challenge = Buffer.from("0123456789abcdef", "ascii"); + await runPreauth({ + preauth: { auth: "vnc-password", credentials: { password: "password" } }, + serverScript: async (server) => { + await server.write(VERSION_3_8); + expect(await server.readExactly(12)).toEqual(VERSION_3_8); + await server.write(Buffer.from([1, 2])); + expect(await server.readExactly(1)).toEqual(Buffer.from([2])); + await server.write(challenge); + expect((await server.readExactly(16)).toString("hex")).toBe( + "5645abeb5f1e6475e8feb11beb66ea19", + ); + await server.write(Buffer.alloc(4)); + }, + }); + }); +}); diff --git a/src/gateway/desktop/rfb-preauth.ts b/src/gateway/desktop/rfb-preauth.ts new file mode 100644 index 000000000000..469c2023a588 --- /dev/null +++ b/src/gateway/desktop/rfb-preauth.ts @@ -0,0 +1,424 @@ +import { createCipheriv, createHash, randomBytes } from "node:crypto"; +import type { Duplex } from "node:stream"; + +const RFB_VERSION_BYTES = 12; +const RFB_3_3_VERSION = Buffer.from("RFB 003.003\n", "ascii"); +const RFB_3_8_VERSION = Buffer.from("RFB 003.008\n", "ascii"); +const RFB_SECURITY_NONE = 1; +const RFB_SECURITY_VNC = 2; +const RFB_SECURITY_ARD = 30; +const MAX_ARD_KEY_BYTES = 1024; +const MAX_REASON_BYTES = 64 * 1024; +const DEFAULT_PREAUTH_TIMEOUT_MS = 10_000; + +export type RfbPreauthDescriptor = + | { + auth: "ard-account"; + credentials: { username: string; password: string }; + } + | { + auth: "vnc-password"; + credentials: { password: string }; + }; + +export type RfbPreauthPeer = { + readExactly(length: number, signal: AbortSignal): Promise; + write(buffer: Buffer, signal: AbortSignal): Promise; +}; + +export class RfbPreauthTimeoutError extends Error { + constructor() { + super("RFB authentication negotiation timed out"); + this.name = "RfbPreauthTimeoutError"; + } +} + +function abortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error + ? signal.reason + : new Error("RFB authentication negotiation aborted"); +} + +/** Exact-byte queue shared by stream and WebSocket handshake adapters. */ +export class RfbPreauthBuffer { + private buffered = Buffer.alloc(0); + private failure: Error | undefined; + private readonly waiters = new Set<() => void>(); + + push(chunk: Buffer): void { + this.buffered = Buffer.concat([this.buffered, chunk]); + this.wake(); + } + + fail(error: Error): void { + this.failure = error; + this.wake(); + } + + private wake(): void { + for (const waiter of this.waiters) { + waiter(); + } + this.waiters.clear(); + } + + private async waitForData(signal: AbortSignal): Promise { + if (signal.aborted) { + throw abortReason(signal); + } + await new Promise((resolve, reject) => { + const cleanup = () => { + this.waiters.delete(onWake); + signal.removeEventListener("abort", onAbort); + }; + const onWake = () => { + cleanup(); + resolve(); + }; + const onAbort = () => { + cleanup(); + reject(abortReason(signal)); + }; + this.waiters.add(onWake); + signal.addEventListener("abort", onAbort, { once: true }); + }); + } + + async readExactly(length: number, signal: AbortSignal): Promise { + while (this.buffered.length < length) { + if (this.failure) { + throw this.failure; + } + await this.waitForData(signal); + } + const value = this.buffered.subarray(0, length); + this.buffered = this.buffered.subarray(length); + return value; + } + + takeBuffered(): Buffer { + const value = this.buffered; + this.buffered = Buffer.alloc(0); + return value; + } +} + +class StreamRfbPreauthPeer implements RfbPreauthPeer { + private readonly reader = new RfbPreauthBuffer(); + + private readonly onData = (chunk: Buffer) => this.reader.push(chunk); + private readonly onEnd = () => { + this.reader.fail(new Error("RFB peer closed during authentication negotiation")); + }; + private readonly onError = (error: Error) => { + this.reader.fail(error); + }; + + constructor(private readonly stream: Duplex) { + stream.on("data", this.onData); + stream.once("end", this.onEnd); + stream.once("close", this.onEnd); + stream.once("error", this.onError); + } + + async readExactly(length: number, signal: AbortSignal): Promise { + return await this.reader.readExactly(length, signal); + } + + async write(buffer: Buffer, signal: AbortSignal): Promise { + if (signal.aborted) { + throw abortReason(signal); + } + await new Promise((resolve, reject) => { + const cleanup = () => signal.removeEventListener("abort", onAbort); + const onAbort = () => { + cleanup(); + reject(abortReason(signal)); + }; + signal.addEventListener("abort", onAbort, { once: true }); + this.stream.write(buffer, (error) => { + cleanup(); + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } + + dispose(): void { + this.stream.off("data", this.onData); + this.stream.off("end", this.onEnd); + this.stream.off("close", this.onEnd); + this.stream.off("error", this.onError); + } +} + +function parseServerVersion(banner: Buffer): { minor: number; reply: Buffer } { + const match = /^RFB 003\.(\d{3})\n$/u.exec(banner.toString("ascii")); + if (!match) { + throw new Error(`unsupported RFB protocol version ${JSON.stringify(banner.toString("ascii"))}`); + } + const offeredMinor = Number.parseInt(match[1] ?? "", 10); + if (offeredMinor === 889 || offeredMinor >= 7) { + return { minor: 8, reply: RFB_3_8_VERSION }; + } + return { minor: 3, reply: RFB_3_3_VERSION }; +} + +async function readReason(peer: RfbPreauthPeer, signal: AbortSignal): Promise { + const length = (await peer.readExactly(4, signal)).readUInt32BE(0); + if (length === 0) { + return ""; + } + if (length > MAX_REASON_BYTES) { + throw new Error("RFB failure reason is too large"); + } + return (await peer.readExactly(length, signal)).toString("utf8"); +} + +async function selectSecurityType(params: { + peer: RfbPreauthPeer; + protocolMinor: number; + requiredType: number; + signal: AbortSignal; +}): Promise { + if (params.protocolMinor < 7) { + const selected = (await params.peer.readExactly(4, params.signal)).readUInt32BE(0); + if (selected === 0) { + const reason = await readReason(params.peer, params.signal); + throw new Error(`RFB server rejected security negotiation${reason ? `: ${reason}` : ""}`); + } + if (selected !== params.requiredType) { + throw new Error(`RFB server selected security type ${selected}, want ${params.requiredType}`); + } + return; + } + + const count = (await params.peer.readExactly(1, params.signal))[0] ?? 0; + if (count === 0) { + const reason = await readReason(params.peer, params.signal); + throw new Error(`RFB server rejected security negotiation${reason ? `: ${reason}` : ""}`); + } + const offered = await params.peer.readExactly(count, params.signal); + if (!offered.includes(params.requiredType)) { + throw new Error( + `RFB server did not offer required security type ${params.requiredType} (offered ${[ + ...offered, + ].join(", ")})`, + ); + } + await params.peer.write(Buffer.from([params.requiredType]), params.signal); +} + +function bufferToBigInt(value: Buffer): bigint { + return value.length === 0 ? 0n : BigInt(`0x${value.toString("hex")}`); +} + +function leftPadBigInt(value: bigint, length: number): Buffer { + const hex = value.toString(16).padStart(2, "0"); + let bytes = Buffer.from(hex.length % 2 === 0 ? hex : `0${hex}`, "hex"); + if (bytes.length > length) { + bytes = bytes.subarray(bytes.length - length); + } + const output = Buffer.alloc(length); + bytes.copy(output, length - bytes.length); + return output; +} + +function modularExponentiation(base: bigint, exponent: bigint, modulus: bigint): bigint { + if (modulus <= 0n) { + throw new Error("invalid ARD Diffie-Hellman modulus"); + } + let result = 1n; + let factor = base % modulus; + let power = exponent; + while (power > 0n) { + if ((power & 1n) === 1n) { + result = (result * factor) % modulus; + } + factor = (factor * factor) % modulus; + power >>= 1n; + } + return result; +} + +function buildArdCredentialsBlock(username: string, password: string): Buffer { + const block = randomBytes(128); + const usernameBytes = Buffer.from(username, "utf8").subarray(0, 63); + const passwordBytes = Buffer.from(password, "utf8").subarray(0, 63); + usernameBytes.copy(block, 0); + block[usernameBytes.length] = 0; + passwordBytes.copy(block, 64); + block[64 + passwordBytes.length] = 0; + return block; +} + +function encryptAesEcb(key: Buffer, plaintext: Buffer): Buffer { + const cipher = createCipheriv("aes-128-ecb", key, null); + cipher.setAutoPadding(false); + return Buffer.concat([cipher.update(plaintext), cipher.final()]); +} + +async function negotiateArdAuth(params: { + peer: RfbPreauthPeer; + credentials: { username: string; password: string }; + signal: AbortSignal; +}): Promise { + const header = await params.peer.readExactly(4, params.signal); + const keyLength = header.readUInt16BE(2); + if (keyLength < 1 || keyLength > MAX_ARD_KEY_BYTES) { + throw new Error(`invalid ARD key length ${keyLength}`); + } + const dhParameters = await params.peer.readExactly(keyLength * 2, params.signal); + const generator = bufferToBigInt(header.subarray(0, 2)); + const modulus = bufferToBigInt(dhParameters.subarray(0, keyLength)); + const serverPublic = bufferToBigInt(dhParameters.subarray(keyLength)); + if (generator === 0n || modulus === 0n || serverPublic === 0n) { + throw new Error("invalid ARD Diffie-Hellman parameters"); + } + + const privateKey = bufferToBigInt(randomBytes(keyLength)); + const clientPublic = modularExponentiation(generator, privateKey, modulus); + const shared = modularExponentiation(serverPublic, privateKey, modulus); + // MD5 and AES-ECB are mandated by ARD/RFB wire compatibility; they do not protect stored data. + const key = createHash("md5").update(leftPadBigInt(shared, keyLength)).digest(); + const encryptedCredentials = encryptAesEcb( + key, + buildArdCredentialsBlock(params.credentials.username, params.credentials.password), + ); + await params.peer.write( + Buffer.concat([encryptedCredentials, leftPadBigInt(clientPublic, keyLength)]), + params.signal, + ); +} + +function reverseByteBits(value: number): number { + let input = value; + let output = 0; + for (let index = 0; index < 8; index += 1) { + output = (output << 1) | (input & 1); + input >>= 1; + } + return output; +} + +function buildVncAuthResponse(password: string, challenge: Buffer): Buffer { + const key = Buffer.alloc(8); + Buffer.from(password, "utf8").copy(key, 0, 0, 8); + for (let index = 0; index < key.length; index += 1) { + key[index] = reverseByteBits(key[index] ?? 0); + } + // RFB mandates single DES. EDE with K1=K2 is the same primitive on OpenSSL builds without des-ecb. + const cipher = createCipheriv("des-ede", Buffer.concat([key, key]), null); + cipher.setAutoPadding(false); + return Buffer.concat([cipher.update(challenge), cipher.final()]); +} + +async function negotiateVncAuth(params: { + peer: RfbPreauthPeer; + password: string; + signal: AbortSignal; +}): Promise { + if (!params.password) { + throw new Error("VNC password is required"); + } + const challenge = await params.peer.readExactly(16, params.signal); + await params.peer.write(buildVncAuthResponse(params.password, challenge), params.signal); +} + +async function readSecurityResult(peer: RfbPreauthPeer, signal: AbortSignal): Promise { + const status = (await peer.readExactly(4, signal)).readUInt32BE(0); + if (status === 0) { + return; + } + let reason = ""; + try { + reason = await readReason(peer, signal); + } catch { + // Older servers may close immediately after the status word. + } + throw new Error( + reason + ? `RFB authentication failed: ${reason}` + : `RFB authentication failed with status ${status}`, + ); +} + +async function negotiateServer(params: { + peer: RfbPreauthPeer; + preauth: RfbPreauthDescriptor; + signal: AbortSignal; +}): Promise { + if ( + params.preauth.auth === "ard-account" && + (!params.preauth.credentials.username || !params.preauth.credentials.password) + ) { + throw new Error("ARD account username and password are required"); + } + const banner = await params.peer.readExactly(RFB_VERSION_BYTES, params.signal); + const version = parseServerVersion(banner); + await params.peer.write(version.reply, params.signal); + const requiredType = params.preauth.auth === "ard-account" ? RFB_SECURITY_ARD : RFB_SECURITY_VNC; + await selectSecurityType({ + peer: params.peer, + protocolMinor: version.minor, + requiredType, + signal: params.signal, + }); + if (params.preauth.auth === "ard-account") { + await negotiateArdAuth({ + peer: params.peer, + credentials: params.preauth.credentials, + signal: params.signal, + }); + } else { + await negotiateVncAuth({ + peer: params.peer, + password: params.preauth.credentials.password, + signal: params.signal, + }); + } + await readSecurityResult(params.peer, params.signal); +} + +async function synthesizeBrowserHandshake( + browser: RfbPreauthPeer, + signal: AbortSignal, +): Promise { + await browser.write(RFB_3_8_VERSION, signal); + const version = await browser.readExactly(RFB_VERSION_BYTES, signal); + if (!version.equals(RFB_3_8_VERSION)) { + throw new Error("RFB browser did not accept protocol version 3.8"); + } + await browser.write(Buffer.from([1, RFB_SECURITY_NONE]), signal); + const selected = await browser.readExactly(1, signal); + if (selected[0] !== RFB_SECURITY_NONE) { + throw new Error("RFB browser did not select no authentication"); + } + await browser.write(Buffer.alloc(4), signal); +} + +/** Authenticates the Gateway to an RFB server, then exposes a synthetic None handshake. */ +export async function preauthenticateRfb(params: { + server: Duplex; + browser: RfbPreauthPeer; + preauth: RfbPreauthDescriptor; + timeoutMs?: number; +}): Promise { + const server = new StreamRfbPreauthPeer(params.server); + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(new RfbPreauthTimeoutError()), + params.timeoutMs ?? DEFAULT_PREAUTH_TIMEOUT_MS, + ); + timeout.unref?.(); + try { + await negotiateServer({ peer: server, preauth: params.preauth, signal: controller.signal }); + await synthesizeBrowserHandshake(params.browser, controller.signal); + } finally { + clearTimeout(timeout); + server.dispose(); + } +} diff --git a/src/gateway/desktop/rfb-probe.test.ts b/src/gateway/desktop/rfb-probe.test.ts new file mode 100644 index 000000000000..0b4639e513b2 --- /dev/null +++ b/src/gateway/desktop/rfb-probe.test.ts @@ -0,0 +1,158 @@ +import net from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { classifyRfbSecurity, probeRfbServer } from "./rfb-probe.js"; + +const cleanups: Array<() => Promise> = []; + +afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); +}); + +/** Serves one scripted RFB handshake so probes exercise the real socket reader. */ +async function listenScriptedRfb(script: (socket: net.Socket) => void): Promise { + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + script(socket); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + cleanups.push(async () => { + for (const socket of sockets) { + socket.destroy(); + } + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }); + const address = server.address(); + if (typeof address === "string" || !address) { + throw new Error("scripted RFB server did not bind a port"); + } + return address.port; +} + +function probe(port: number) { + return probeRfbServer({ host: "127.0.0.1", port, timeoutMs: 2_000 }); +} + +describe("RFB server probe", () => { + it.each([ + ["macOS Screen Sharing", "RFB 003.889\n", [30], [30]], + ["TigerVNC", "RFB 003.008\n", [2], [2]], + ["wayvnc", "RFB 003.008\n", [1], [1]], + ["gnome-remote-desktop", "RFB 003.008\n", [19], [19]], + ])("reads the %s security offer", async (_name, banner, offered, expected) => { + const port = await listenScriptedRfb((socket) => { + socket.write(Buffer.from(banner, "ascii")); + socket.once("data", (reply) => { + expect(reply.toString("ascii")).toBe("RFB 003.008\n"); + socket.write(Buffer.from([offered.length, ...offered])); + }); + }); + await expect(probe(port)).resolves.toEqual({ kind: "rfb", securityTypes: expected }); + }); + + it("reassembles a handshake split across packets", async () => { + const port = await listenScriptedRfb((socket) => { + socket.write(Buffer.from("RFB 003", "ascii")); + setTimeout(() => socket.write(Buffer.from(".008\n", "ascii")), 5); + socket.once("data", () => { + socket.write(Buffer.from([2])); + setTimeout(() => socket.write(Buffer.from([2, 30])), 5); + }); + }); + await expect(probe(port)).resolves.toEqual({ kind: "rfb", securityTypes: [2, 30] }); + }); + + it("negotiates the legacy RFB 3.3 single security word", async () => { + const port = await listenScriptedRfb((socket) => { + socket.write(Buffer.from("RFB 003.003\n", "ascii")); + socket.once("data", (reply) => { + expect(reply.toString("ascii")).toBe("RFB 003.003\n"); + socket.write(Buffer.from([0, 0, 0, 2])); + }); + }); + await expect(probe(port)).resolves.toEqual({ kind: "rfb", securityTypes: [2] }); + }); + + it("does not negotiate above an RFB 3.7 server", async () => { + const port = await listenScriptedRfb((socket) => { + socket.write(Buffer.from("RFB 003.007\n", "ascii")); + socket.once("data", (reply) => { + expect(reply.toString("ascii")).toBe("RFB 003.007\n"); + socket.write(Buffer.from([1, 2])); + }); + }); + await expect(probe(port)).resolves.toEqual({ kind: "rfb", securityTypes: [2] }); + }); + + it("surfaces a rejected handshake as an empty security offer", async () => { + const port = await listenScriptedRfb((socket) => { + socket.write(Buffer.from("RFB 003.008\n", "ascii")); + socket.once("data", () => { + const reason = Buffer.from("too many auth failures", "ascii"); + const header = Buffer.alloc(5); + header.writeUInt8(0, 0); + header.writeUInt32BE(reason.length, 1); + socket.write(Buffer.concat([header, reason])); + }); + }); + await expect(probe(port)).resolves.toEqual({ kind: "rfb", securityTypes: [] }); + }); + + it.each([ + ["RFB 3.3", "RFB 003.003\n", Buffer.alloc(4)], + ["RFB 3.8", "RFB 003.008\n", Buffer.from([0])], + ])("does not buffer the %s failure reason", async (_name, banner, rejection) => { + const port = await listenScriptedRfb((socket) => { + socket.write(Buffer.from(banner, "ascii")); + socket.once("data", () => { + const reasonLength = Buffer.alloc(4); + reasonLength.writeUInt32BE(0xffff_ffff); + socket.write(Buffer.concat([rejection, reasonLength])); + }); + }); + await expect(probe(port)).resolves.toEqual({ kind: "rfb", securityTypes: [] }); + }); + + it("reports a non-RFB occupant without reading past its banner", async () => { + const port = await listenScriptedRfb((socket) => { + socket.write(Buffer.from("HTTP/1.1 200 OK\r\n\r\n", "ascii")); + }); + await expect(probe(port)).resolves.toEqual({ kind: "not-rfb", banner: "HTTP/1.1 200" }); + }); + + it("reports a truncated banner when the server hangs up early", async () => { + const port = await listenScriptedRfb((socket) => { + socket.end(Buffer.from("RFB 003", "ascii")); + }); + await expect(probe(port)).resolves.toEqual({ kind: "not-rfb", banner: "RFB 003" }); + }); + + it("reports an unreachable port", async () => { + const port = await listenScriptedRfb(() => undefined); + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); + await expect(probe(port)).resolves.toEqual({ kind: "unreachable" }); + }); + + it("times out a server that never speaks", async () => { + const port = await listenScriptedRfb(() => undefined); + await expect(probeRfbServer({ host: "127.0.0.1", port, timeoutMs: 50 })).resolves.toEqual({ + kind: "timeout", + }); + }); +}); + +describe("RFB security classification", () => { + it("classifies supported security with password auth preferred over ARD", () => { + expect(classifyRfbSecurity([1])).toBe("none"); + expect(classifyRfbSecurity([30])).toBe("ard-account"); + expect(classifyRfbSecurity([19])).toBe("unsupported"); + expect(classifyRfbSecurity([30, 2])).toBe("vnc-password"); + expect(classifyRfbSecurity([30, 33, 36, 35])).toBe("ard-account"); + }); +}); diff --git a/src/gateway/desktop/rfb-probe.ts b/src/gateway/desktop/rfb-probe.ts new file mode 100644 index 000000000000..d61c2d90ddef --- /dev/null +++ b/src/gateway/desktop/rfb-probe.ts @@ -0,0 +1,205 @@ +import net from "node:net"; + +const RFB_BANNER_BYTES = 12; +const RFB_37_MINOR = 7; +const RFB_37_BANNER = Buffer.from("RFB 003.007\n", "ascii"); +const RFB_38_BANNER = Buffer.from("RFB 003.008\n", "ascii"); + +export type RfbProbeResult = + | { kind: "rfb"; securityTypes: number[] } + | { kind: "not-rfb"; banner: string } + | { kind: "unreachable" } + | { kind: "timeout" }; + +type ParsedRfbVersion = { + kind: "rfb"; + minor: number; + reply: Buffer; +}; + +/** Parses the fixed-width RFB ProtocolVersion banner without socket state. */ +function parseRfbVersionBanner( + buffer: Buffer, +): ParsedRfbVersion | { kind: "not-rfb"; banner: string } { + const banner = buffer.subarray(0, RFB_BANNER_BYTES).toString("ascii"); + if (buffer.length < RFB_BANNER_BYTES) { + return { kind: "not-rfb", banner }; + } + const match = /^RFB 003\.(\d{3})\n$/u.exec(banner); + if (!match) { + return { kind: "not-rfb", banner }; + } + const minor = Number.parseInt(match[1] ?? "", 10); + return { + kind: "rfb", + minor, + reply: + minor > RFB_37_MINOR + ? RFB_38_BANNER + : minor === RFB_37_MINOR + ? RFB_37_BANNER + : Buffer.from("RFB 003.003\n", "ascii"), + }; +} + +type ParsedRfbSecurity = + | { kind: "complete"; securityTypes: number[]; bytesConsumed: number } + | { kind: "incomplete"; requiredBytes: number }; + +/** Parses the post-version RFB security offer from a standalone buffer. */ +function parseRfbSecurityTypes(buffer: Buffer, protocolMinor: number): ParsedRfbSecurity { + if (protocolMinor < RFB_37_MINOR) { + if (buffer.length < 4) { + return { kind: "incomplete", requiredBytes: 4 }; + } + const securityType = buffer.readUInt32BE(0); + return { + kind: "complete", + securityTypes: securityType === 0 ? [] : [securityType], + bytesConsumed: 4, + }; + } + + if (buffer.length < 1) { + return { kind: "incomplete", requiredBytes: 1 }; + } + const count = buffer.readUInt8(0); + if (count > 0) { + const requiredBytes = 1 + count; + return buffer.length < requiredBytes + ? { kind: "incomplete", requiredBytes } + : { + kind: "complete", + securityTypes: [...buffer.subarray(1, requiredBytes)], + bytesConsumed: requiredBytes, + }; + } + return { kind: "complete", securityTypes: [], bytesConsumed: 1 }; +} + +class SocketEndedError extends Error { + constructor(readonly buffered: Buffer) { + super("RFB server closed the handshake early"); + } +} + +class SocketTimeoutError extends Error {} + +function createSocketReader(socket: net.Socket) { + let buffered = Buffer.alloc(0); + let ended = false; + let failure: Error | undefined; + const waiters = new Set<() => void>(); + const wake = () => { + for (const waiter of waiters) { + waiter(); + } + waiters.clear(); + }; + socket.on("data", (chunk: Buffer) => { + buffered = Buffer.concat([buffered, chunk]); + wake(); + }); + socket.once("end", () => { + ended = true; + wake(); + }); + socket.once("error", (error) => { + failure = error; + wake(); + }); + socket.once("timeout", () => { + failure = new SocketTimeoutError("RFB handshake timed out"); + wake(); + }); + + return { + async readExactly(length: number): Promise { + while (buffered.length < length) { + if (failure) { + throw failure; + } + if (ended) { + throw new SocketEndedError(buffered); + } + await new Promise((resolve) => { + waiters.add(resolve); + }); + } + const value = buffered.subarray(0, length); + buffered = buffered.subarray(length); + return value; + }, + }; +} + +/** Connects to a loopback RFB server and reads only its version and security offer. */ +export async function probeRfbServer(params: { + host: "127.0.0.1"; + port: number; + timeoutMs: number; +}): Promise { + const socket = net.createConnection(params.port, params.host); + const deadline = setTimeout(() => { + socket.destroy(new SocketTimeoutError("RFB handshake timed out")); + }, params.timeoutMs); + deadline.unref(); + const reader = createSocketReader(socket); + try { + await new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("error", reject); + }); + let bannerBytes: Buffer; + try { + bannerBytes = await reader.readExactly(RFB_BANNER_BYTES); + } catch (error) { + if (error instanceof SocketEndedError) { + return { kind: "not-rfb", banner: error.buffered.toString("ascii") }; + } + throw error; + } + const version = parseRfbVersionBanner(bannerBytes); + if (version.kind === "not-rfb") { + return version; + } + socket.write(version.reply); + + const prefixBytes = version.minor < RFB_37_MINOR ? 4 : 1; + let securityBuffer = await reader.readExactly(prefixBytes); + let parsed = parseRfbSecurityTypes(securityBuffer, version.minor); + while (parsed.kind === "incomplete") { + securityBuffer = Buffer.concat([ + securityBuffer, + await reader.readExactly(parsed.requiredBytes - securityBuffer.length), + ]); + parsed = parseRfbSecurityTypes(securityBuffer, version.minor); + } + return { kind: "rfb", securityTypes: parsed.securityTypes }; + } catch (error) { + if (error instanceof SocketTimeoutError) { + return { kind: "timeout" }; + } + return { kind: "unreachable" }; + } finally { + clearTimeout(deadline); + socket.end(); + socket.destroy(); + } +} + +/** Maps standard RFB security numbers into the credential UX supported by OpenClaw. */ +export function classifyRfbSecurity( + securityTypes: readonly number[], +): "none" | "vnc-password" | "ard-account" | "unsupported" { + if (securityTypes.includes(2)) { + return "vnc-password"; + } + if (securityTypes.includes(30)) { + return "ard-account"; + } + if (securityTypes.includes(1)) { + return "none"; + } + return "unsupported"; +} diff --git a/src/gateway/desktop/rfb-view-only-filter.test.ts b/src/gateway/desktop/rfb-view-only-filter.test.ts index c18463b8d552..4a4c625932fb 100644 --- a/src/gateway/desktop/rfb-view-only-filter.test.ts +++ b/src/gateway/desktop/rfb-view-only-filter.test.ts @@ -53,6 +53,16 @@ describe("RFB view-only client message filter", () => { }); }); + it("starts at ClientInit after server-side authentication without forwarding input", () => { + const filter = createRfbClientMessageFilter({ startPhase: "clientInit" }); + const keyEvent = Buffer.from([4, 1, 0, 0, 0, 0, 0, 65]); + const framebufferRequest = Buffer.from([3, 1, 0, 0, 0, 0, 0, 64, 0, 64]); + + expect(filter.filter(Buffer.concat([Buffer.from([0]), keyEvent, framebufferRequest]))).toEqual({ + forward: Buffer.concat([Buffer.from([1]), framebufferRequest]), + }); + }); + it("fails closed on unsupported security types", () => { const filter = createRfbClientMessageFilter(); expect(filter.filter(Buffer.concat([VERSION, Buffer.from([19])]))).toEqual({ diff --git a/src/gateway/desktop/rfb-view-only-filter.ts b/src/gateway/desktop/rfb-view-only-filter.ts index bfee894c1a35..50c9e483f670 100644 --- a/src/gateway/desktop/rfb-view-only-filter.ts +++ b/src/gateway/desktop/rfb-view-only-filter.ts @@ -14,8 +14,10 @@ type RfbClientMessageFilterResult = | { forward?: never; error: string }; /** Filters one view-only RFB client byte stream without trusting WebSocket frame boundaries. */ -export function createRfbClientMessageFilter() { - let phase: RfbClientPhase = "version"; +export function createRfbClientMessageFilter( + options: { startPhase?: "version" | "clientInit" } = {}, +) { + let phase: RfbClientPhase = options.startPhase ?? "version"; let pending = Buffer.alloc(0); let failure: string | undefined; diff --git a/src/gateway/desktop/session-registry.ts b/src/gateway/desktop/session-registry.ts index ff8154a366aa..65230f66a95b 100644 --- a/src/gateway/desktop/session-registry.ts +++ b/src/gateway/desktop/session-registry.ts @@ -26,6 +26,7 @@ type DesktopSessionObserver = { type DesktopSessionAcquireResult = { attachment: RfbAttachment; + auth?: "vnc-password" | "ard-account"; vncPassword?: string; }; diff --git a/src/gateway/methods/core-descriptors.since.test.ts b/src/gateway/methods/core-descriptors.since.test.ts index a8ef2bec0a25..3a34ed3c2982 100644 --- a/src/gateway/methods/core-descriptors.since.test.ts +++ b/src/gateway/methods/core-descriptors.since.test.ts @@ -96,6 +96,8 @@ const CURRENT_TRAIN_METHODS = [ "secrets.store.delete", "users.prefs.get", "users.prefs.set", + "desktop.observe", + "desktop.launch", ] as const; describe("core gateway method release trains", () => { diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 1dfa3aabde84..8940966c6fa3 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -514,6 +514,8 @@ const CORE_GATEWAY_METHOD_SPECS = [ "2026.8", { description: "Search GitHub repositories that can be cloned as managed projects." }, ], + ["desktop.observe", "environments", "operator.admin", "2026.8", { startup: true }], + ["desktop.launch", "environments", "operator.admin", "2026.8", { startup: true }], ] as const satisfies readonly CoreGatewayMethodSpecRow[]; export type CoreGatewayHandlerFamily = Exclude<(typeof CORE_GATEWAY_METHOD_SPECS)[number][1], null>; diff --git a/src/gateway/server-core-runtime.ts b/src/gateway/server-core-runtime.ts index d59fb5856de9..e9611921172a 100644 --- a/src/gateway/server-core-runtime.ts +++ b/src/gateway/server-core-runtime.ts @@ -131,6 +131,8 @@ export async function startGatewayCoreRuntime(input: { workerPlacementDispatchAvailable, workerPlacementControlAvailable, workerDesktopObserveAvailable, + desktopObserveAvailable, + desktopSessionRegistry, listStartupChannelGatewayMethods, coreGatewayMethodNames, pluginHostServices, @@ -142,6 +144,9 @@ export async function startGatewayCoreRuntime(input: { activateRuntimeSecrets, residentRegistry, } = runtime; + if (desktopSessionRegistry) { + kernel.addGatewayLifetimeSidecar({ stop: () => desktopSessionRegistry.stopAll() }); + } let earlyRuntimePromise: ReturnType< Awaited>["startGatewayEarlyRuntime"] > | null = null; @@ -372,8 +377,10 @@ export async function startGatewayCoreRuntime(input: { descriptor.name !== "environments.destroy")) && (workerPlacementDispatchAvailable || descriptor.name !== "sessions.dispatch") && (workerPlacementControlAvailable || descriptor.name !== "sessions.reclaim") && + (desktopObserveAvailable || descriptor.name !== "desktop.observe") && (workerDesktopObserveAvailable || - (descriptor.name !== "worker.desktop.observe" && + (descriptor.name !== "desktop.launch" && + descriptor.name !== "worker.desktop.observe" && descriptor.name !== "worker.desktop.launch")), ); return createGatewayMethodRegistry( diff --git a/src/gateway/server-kernel-request-runtime.ts b/src/gateway/server-kernel-request-runtime.ts index c30e15196009..7b47f29d0e34 100644 --- a/src/gateway/server-kernel-request-runtime.ts +++ b/src/gateway/server-kernel-request-runtime.ts @@ -64,6 +64,7 @@ export async function prepareGatewayKernelRequestRuntime(params: { releaseControlUiDeviceAuthMigrationClaim, nodeRegistry, workerEnvironmentService, + hostDesktopService, workerEnvironmentStartup, workerPlacementControlAvailable, terminalSessions, @@ -163,6 +164,7 @@ export async function prepareGatewayKernelRequestRuntime(params: { releaseControlUiDeviceAuthMigrationClaim(deviceId, { env: process.env }), nodeRegistry, ...(workerEnvironmentService ? { workerEnvironmentService } : {}), + ...(hostDesktopService ? { hostDesktopService } : {}), ...(workerEnvironmentStartup ? { workerSessionPlacementService: workerEnvironmentStartup.placementStore } : {}), diff --git a/src/gateway/server-methods-list.test.ts b/src/gateway/server-methods-list.test.ts index eade74d3317c..6a5da66de0d8 100644 --- a/src/gateway/server-methods-list.test.ts +++ b/src/gateway/server-methods-list.test.ts @@ -66,7 +66,7 @@ describe("listGatewayMethods", () => { }); it("appends new methods after model probing without shifting older method indices", () => { - expect(listGatewayMethods().slice(-46)).toEqual([ + expect(listGatewayMethods().slice(-48)).toEqual([ "models.probe", "migrations.memory.plan", "migrations.memory.apply", @@ -113,6 +113,8 @@ describe("listGatewayMethods", () => { "users.prefs.set", "projects.add", "projects.searchRemote", + "desktop.observe", + "desktop.launch", ]); const methods = listGatewayMethods(); expect(methods.indexOf("node.pluginSurface.refresh")).toBe( @@ -204,7 +206,7 @@ describe("listGatewayMethods", () => { "exec.approval.get", ]); expect(methods).toContain("tts.speak"); - expect(coreMethods.slice(-53)).toEqual([ + expect(coreMethods.slice(-55)).toEqual([ "sessions.catalog.continue", "sessions.catalog.archive", "approval.get", @@ -258,6 +260,8 @@ describe("listGatewayMethods", () => { "users.prefs.set", "projects.add", "projects.searchRemote", + "desktop.observe", + "desktop.launch", ]); expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak")); expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1); @@ -283,6 +287,8 @@ describe("listGatewayMethods", () => { expect(methods.indexOf("users.prefs.set")).toBe(methods.indexOf("users.prefs.get") + 1); expect(methods.indexOf("projects.add")).toBe(methods.indexOf("users.prefs.set") + 1); expect(methods.indexOf("projects.searchRemote")).toBe(methods.indexOf("projects.add") + 1); + expect(methods.indexOf("desktop.observe")).toBe(methods.indexOf("projects.searchRemote") + 1); + expect(methods.indexOf("desktop.launch")).toBe(methods.indexOf("desktop.observe") + 1); }); it("advertises the versioned Talk session RPCs", () => { diff --git a/src/gateway/server-methods/environments.desktop.test.ts b/src/gateway/server-methods/environments.desktop.test.ts new file mode 100644 index 000000000000..18f61b9491e9 --- /dev/null +++ b/src/gateway/server-methods/environments.desktop.test.ts @@ -0,0 +1,168 @@ +import net from "node:net"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js"; +import { HostDesktopCredentialsRequiredError } from "../desktop/host-source-errors.js"; +import { createHostDesktopService } from "../desktop/host-source.js"; +import { createDesktopSessionRegistry } from "../desktop/session-registry.js"; +import { environmentsHandlers } from "./environments.js"; + +const cleanups: Array<() => Promise> = []; + +afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); +}); + +async function invoke( + method: "desktop.observe" | "worker.desktop.observe", + params: unknown, + context: object, +) { + const respond = vi.fn(); + await environmentsHandlers[method]?.({ params, respond, context } as never); + const call = respond.mock.calls.at(0); + if (!call) { + throw new Error("expected desktop handler response"); + } + return call; +} + +describe("desktop gateway methods", () => { + it("names the Labs config and restart when host desktop is disabled", async () => { + const [ok, , error] = await invoke( + "desktop.observe", + { source: { kind: "host" } }, + { getRuntimeConfig: () => ({}) }, + ); + expect(ok).toBe(false); + expect(error).toEqual({ + code: ErrorCodes.INVALID_REQUEST, + message: + "gateway host desktop is disabled; enable the Desktop lab (config: desktop.host.enabled=true), then restart the gateway", + }); + }); + + it("returns a host observer token and auth from a real loopback RFB server", async () => { + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + socket.write(Buffer.from("RFB 003.008\n", "ascii")); + socket.once("data", () => socket.write(Buffer.from([1, 2]))); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("expected RFB address"); + } + cleanups.push( + async () => + await new Promise((resolve) => { + for (const socket of sockets) { + socket.destroy(); + } + server.close(() => resolve()); + }), + ); + const registry = createDesktopSessionRegistry({ lingerMs: 10 }); + cleanups.push(async () => registry.stopAll()); + const config = { enabled: true, port: address.port }; + const [ok, result] = await invoke( + "desktop.observe", + { source: { kind: "host" }, control: true }, + { + getRuntimeConfig: () => ({ desktop: { host: config } }), + hostDesktopService: createHostDesktopService({ config, registry }), + }, + ); + expect(ok).toBe(true); + expect(result).toMatchObject({ + transport: "rfb", + control: true, + auth: "vnc-password", + }); + expect(result.wsPath).toMatch(/^\/desktop\/observe\?token=[a-f0-9]{48}$/u); + }); + + it("keeps the worker alias identical to the generic environment arm", async () => { + const workerEnvironmentService = { + observeDesktop: vi.fn(async ({ control }: { control: boolean }) => ({ + transport: "rfb" as const, + wsPath: "/desktop/observe?token=fixed", + expiresAtMs: 42, + control, + vncPassword: "password", + })), + }; + const context = { workerEnvironmentService }; + const alias = await invoke( + "worker.desktop.observe", + { environmentId: "worker:one", control: false }, + context, + ); + const generic = await invoke( + "desktop.observe", + { source: { kind: "environment", environmentId: "worker:one" }, control: false }, + context, + ); + expect(alias).toEqual(generic); + expect(alias[1]).not.toHaveProperty("auth"); + }); + + it("reports ARD credentials as required and forwards an in-memory retry", async () => { + const observe = vi.fn( + async (params: { credentials?: { username?: string; password?: string } }) => { + if (!params.credentials) { + throw new HostDesktopCredentialsRequiredError(); + } + return { + transport: "rfb" as const, + wsPath: "/desktop/observe?token=fixed", + expiresAtMs: 42, + control: false, + auth: "ard-account" as const, + }; + }, + ); + const context = { + getRuntimeConfig: () => ({ desktop: { host: { enabled: true } } }), + hostDesktopService: { observe }, + }; + const [firstOk, , firstError] = await invoke( + "desktop.observe", + { source: { kind: "host" } }, + context, + ); + expect(firstOk).toBe(false); + expect(firstError).toMatchObject({ + code: ErrorCodes.INVALID_REQUEST, + details: { + code: "DESKTOP_CREDENTIALS_REQUIRED", + auth: "ard-account", + }, + }); + + const credentials = { username: "operator", password: "account-password" }; + const [retryOk, result] = await invoke( + "desktop.observe", + { source: { kind: "host" }, credentials }, + context, + ); + expect(retryOk).toBe(true); + expect(result).toMatchObject({ auth: "ard-account" }); + expect(result).not.toHaveProperty("vncPassword"); + expect(observe).toHaveBeenLastCalledWith({ control: false, credentials }); + }); + + it("rejects unknown desktop source kinds before dispatch", async () => { + const [ok, , error] = await invoke( + "desktop.observe", + { source: { kind: "node", nodeId: "one" } }, + {}, + ); + expect(ok).toBe(false); + expect(error.code).toBe(ErrorCodes.INVALID_REQUEST); + }); +}); diff --git a/src/gateway/server-methods/environments.test.ts b/src/gateway/server-methods/environments.test.ts index 938612464594..20e34ceaad34 100644 --- a/src/gateway/server-methods/environments.test.ts +++ b/src/gateway/server-methods/environments.test.ts @@ -71,6 +71,14 @@ function mockContext( ], }, workerEnvironmentService, + getRuntimeConfig: () => ({ + cloudWorkers: { + profiles: { + zeta: { provider: "static-ssh", settings: {} }, + aws: { provider: "crabbox", settings: {} }, + }, + }, + }), ...(workerEnvironmentService ? { workerPlacementDispatchService: { @@ -78,14 +86,6 @@ function mockContext( forceDestroyEnvironment, reconcileActive, }, - getRuntimeConfig: () => ({ - cloudWorkers: { - profiles: { - zeta: { provider: "static-ssh", settings: {} }, - aws: { provider: "crabbox", settings: {} }, - }, - }, - }), } : {}), }; diff --git a/src/gateway/server-methods/environments.ts b/src/gateway/server-methods/environments.ts index abd31671bf17..47cbc8d681d4 100644 --- a/src/gateway/server-methods/environments.ts +++ b/src/gateway/server-methods/environments.ts @@ -1,8 +1,11 @@ import { normalizeSortedUniqueTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; import { + type DesktopObserveParams, type EnvironmentSummary, ErrorCodes, errorShape, + validateDesktopLaunchParams, + validateDesktopObserveParams, validateEnvironmentsCreateParams, validateEnvironmentsDestroyParams, validateEnvironmentsListParams, @@ -13,6 +16,7 @@ import { import { listNodePairing } from "../../infra/device-pairing-node.js"; import { listDevicePairing, resolveNodePairingState } from "../../infra/device-pairing.js"; import type { NodeListNode } from "../../shared/node-list-types.js"; +import { isHostDesktopCredentialsRequiredError } from "../desktop/host-source-errors.js"; import { createKnownNodeCatalog, listKnownNodes } from "../node-catalog.js"; import type { WorkerEnvironmentServiceRecord } from "../worker-environments/service-contract.js"; import type { WorkerEnvironmentState } from "../worker-environments/state.js"; @@ -81,6 +85,7 @@ export function summarizeWorkerEnvironment( ...(record.sharedHost === null ? {} : { trust: record.sharedHost ? "persistent" : "disposable" }), + ...(record.desktopAvailable ? { desktop: true } : {}), worker: { providerId: record.providerId, ...(record.leaseId ? { leaseId: record.leaseId } : {}), @@ -116,7 +121,11 @@ async function listEnvironments(context: GatewayRequestContext): Promise { if (!validateEnvironmentsListParams(params)) { @@ -267,69 +415,41 @@ export const environmentsHandlers: GatewayRequestHandlers = { if (!validateWorkerDesktopObserveParams(params)) { return rejectInvalid(respond, "worker.desktop.observe", validateWorkerDesktopObserveParams); } - const service = context.workerEnvironmentService; - if (!service) { - respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown environmentId")); - return; - } - try { - respond( - true, - await service.observeDesktop({ - environmentId: params.environmentId, - control: params.control ?? false, - }), - undefined, - ); - } catch (error) { - const code = error && typeof error === "object" && "code" in error ? error.code : undefined; - const invalid = code === "environment_not_found" || code === "invalid_state"; - respond( - false, - undefined, - errorShape( - invalid ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE, - invalid && error instanceof Error ? error.message : "worker desktop observe unavailable", - ), - ); - } + await respondDesktopObserve({ + request: { + source: { kind: "environment", environmentId: params.environmentId }, + ...(params.control === undefined ? {} : { control: params.control }), + }, + respond, + context, + }); }, "worker.desktop.launch": async ({ params, respond, context }) => { if (!validateWorkerDesktopLaunchParams(params)) { return rejectInvalid(respond, "worker.desktop.launch", validateWorkerDesktopLaunchParams); } - const service = context.workerEnvironmentService; - if (!service) { - respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown environmentId")); - return; + await respondDesktopLaunch({ + environmentId: params.environmentId, + app: params.app, + respond, + context, + }); + }, + "desktop.observe": async ({ params, respond, context }) => { + if (!validateDesktopObserveParams(params)) { + return rejectInvalid(respond, "desktop.observe", validateDesktopObserveParams); } - try { - respond( - true, - await service.launchDesktopApp({ - environmentId: params.environmentId, - app: params.app, - }), - undefined, - ); - } catch (error) { - const code = error && typeof error === "object" && "code" in error ? error.code : undefined; - const invalid = - code === "environment_not_found" || - code === "invalid_state" || - code === "desktop_app_not_found" || - code === "unsupported_platform"; - const actionable = invalid || code === "launcher_failure"; - respond( - false, - undefined, - errorShape( - invalid ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE, - actionable && error instanceof Error - ? error.message - : "worker desktop app launch unavailable; try again", - ), - ); + await respondDesktopObserve({ request: params, respond, context }); + }, + "desktop.launch": async ({ params, respond, context }) => { + if (!validateDesktopLaunchParams(params)) { + return rejectInvalid(respond, "desktop.launch", validateDesktopLaunchParams); } + await respondDesktopLaunch({ + environmentId: params.source.environmentId, + app: params.app, + respond, + context, + }); }, }; diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 309c286121d1..94d28f083f8d 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -341,6 +341,8 @@ type GatewayResidentBridgeContext = { }) => Promise; /** Durable cloud-worker lifecycle; absent from lightweight in-process contexts. */ workerEnvironmentService?: WorkerEnvironmentServiceContract; + /** Gateway-host desktop acquisition and observation; present only after enabled startup. */ + hostDesktopService?: import("../desktop/host-source.js").HostDesktopService; /** Durable per-session worker placement; absent only from lightweight in-process contexts. */ workerSessionPlacementService?: WorkerSessionPlacementReader & Partial; diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index f1372f4c19b5..77d130806fdb 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -80,6 +80,7 @@ type GatewayRequestContextParams = { }) => void; nodeRegistry: GatewayRequestContext["nodeRegistry"]; workerEnvironmentService?: GatewayRequestContext["workerEnvironmentService"]; + hostDesktopService?: GatewayRequestContext["hostDesktopService"]; workerSessionPlacementService?: GatewayRequestContext["workerSessionPlacementService"]; workerPlacementDispatchService?: GatewayRequestContext["workerPlacementDispatchService"]; validateAgentRuntimeApprovalAuthority: GatewayRequestContext["validateAgentRuntimeApprovalAuthority"]; @@ -367,6 +368,7 @@ export function createGatewayRequestContext( ...(params.workerEnvironmentService ? { workerEnvironmentService: params.workerEnvironmentService } : {}), + ...(params.hostDesktopService ? { hostDesktopService: params.hostDesktopService } : {}), ...(params.workerSessionPlacementService ? { workerSessionPlacementService: params.workerSessionPlacementService } : {}), diff --git a/src/gateway/server-runtime-state-prepare.ts b/src/gateway/server-runtime-state-prepare.ts index 1debfa0bdd00..231641ad98be 100644 --- a/src/gateway/server-runtime-state-prepare.ts +++ b/src/gateway/server-runtime-state-prepare.ts @@ -115,12 +115,27 @@ export async function prepareGatewayKernelState(params: { hasConfiguredWorkerProfiles || Boolean(workerEnvironmentStartup?.records.length) || Boolean(workerEnvironmentStartup?.hasNonlocalPlacementRecords); + const hostDesktopConfig = gatewayPluginConfigAtStart.desktop?.host; + const hostDesktopEnabled = hostDesktopConfig?.enabled === true; const workerGatewayEndpoint = { resolve: (() => undefined) as () => { host: "127.0.0.1" | "::1"; port: number } | undefined, }; - const desktopSessionRegistry = shouldStartWorkerEnvironmentService - ? createDesktopSessionRegistry() - : undefined; + const desktopSessionRegistry = + shouldStartWorkerEnvironmentService || hostDesktopEnabled + ? createDesktopSessionRegistry() + : undefined; + const hostDesktopService = + hostDesktopConfig && hostDesktopEnabled && desktopSessionRegistry + ? ( + await startupTrace.measure( + "host-desktop.runtime-import", + () => import("./desktop/host-source.js"), + ) + ).createHostDesktopService({ + config: hostDesktopConfig, + registry: desktopSessionRegistry, + }) + : undefined; const workerEnvironmentRuntime = workerEnvironmentStartup && desktopSessionRegistry ? await startupTrace.measure("worker-environments.runtime-imports", async () => { @@ -134,8 +149,7 @@ export async function prepareGatewayKernelState(params: { }); }) : {}; - const { workerEnvironmentService, workerLiveEvents, workerTunnelManager } = - workerEnvironmentRuntime; + const { workerEnvironmentService, workerLiveEvents } = workerEnvironmentRuntime; // Assigned once approval managers exist; placement dispatch must not run before then. const workerDispatchAuthority = { revoke: (_params: { sessionId: string; sessionKeys: readonly string[] }): void => { @@ -167,6 +181,7 @@ export async function prepareGatewayKernelState(params: { : undefined; const workerDesktopObserveAvailable = Boolean(workerEnvironmentService) && gatewayPluginConfigAtStart.cloudWorkers?.desktop === true; + const desktopObserveAvailable = workerDesktopObserveAvailable || Boolean(hostDesktopService); const channelLogs = Object.fromEntries( listGatewayStartupChannelPlugins().map((plugin) => [plugin.id, logChannels.child(plugin.id)]), ) as Record>; @@ -188,8 +203,11 @@ export async function prepareGatewayKernelState(params: { (method) => (workerPlacementDispatchAvailable || method !== "sessions.dispatch") && (workerPlacementControlAvailable || method !== "sessions.reclaim") && + (desktopObserveAvailable || method !== "desktop.observe") && (workerDesktopObserveAvailable || - (method !== "worker.desktop.observe" && method !== "worker.desktop.launch")), + (method !== "desktop.launch" && + method !== "worker.desktop.observe" && + method !== "worker.desktop.launch")), ); const runtimeConfig = await startupTrace.measure("runtime.config", async () => { const { resolveGatewayRuntimeConfig } = await import("./server-runtime-config.js"); @@ -421,7 +439,7 @@ export async function prepareGatewayKernelState(params: { handleWatchNodeRequest: async (req: IncomingMessage, res: ServerResponse) => (await watchNodeRequestHandler.current?.(req, res)) ?? false, workerIngressEnabled: Boolean(workerEnvironmentService), - desktopSessionRegistry: workerTunnelManager ? desktopSessionRegistry : undefined, + desktopSessionRegistry, clients: connectionState.clients, }); const { @@ -453,6 +471,9 @@ export async function prepareGatewayKernelState(params: { workerPlacementControlAvailable, workerPlacementDispatchAvailable, workerDesktopObserveAvailable, + desktopObserveAvailable, + desktopSessionRegistry, + hostDesktopService, channelLogs, channelRuntimeEnvs, listStartupChannelGatewayMethods, diff --git a/src/gateway/server.worker-desktop-advertisement.test.ts b/src/gateway/server.worker-desktop-advertisement.test.ts index 342556eab457..e6e8ad0465c1 100644 --- a/src/gateway/server.worker-desktop-advertisement.test.ts +++ b/src/gateway/server.worker-desktop-advertisement.test.ts @@ -28,6 +28,8 @@ describe("cloud worker desktop method advertisement", () => { const methods = (hello as { features?: { methods?: string[] } }).features?.methods ?? []; expect(methods).toContain("sessions.dispatch"); + expect(methods.includes("desktop.observe")).toBe(testCase.advertised); + expect(methods.includes("desktop.launch")).toBe(testCase.advertised); expect(methods.includes("worker.desktop.observe")).toBe(testCase.advertised); expect(methods.includes("worker.desktop.launch")).toBe(testCase.advertised); } finally { @@ -35,4 +37,21 @@ describe("cloud worker desktop method advertisement", () => { await server.close(); } }); + + it("advertises host observe without worker-only desktop methods", async () => { + process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = "0"; + await writeConfigFile({ desktop: { host: { enabled: true } } }); + const { server, ws } = await startServerWithClient(undefined, { auth: { mode: "none" } }); + try { + const hello = await connectOk(ws); + const methods = (hello as { features?: { methods?: string[] } }).features?.methods ?? []; + expect(methods).toContain("desktop.observe"); + expect(methods).not.toContain("desktop.launch"); + expect(methods).not.toContain("worker.desktop.observe"); + expect(methods).not.toContain("worker.desktop.launch"); + } finally { + ws.close(); + await server.close(); + } + }); }); diff --git a/src/status/summary.ts b/src/status/summary.ts index c4c00a78cb35..c1743a2ecea3 100644 --- a/src/status/summary.ts +++ b/src/status/summary.ts @@ -590,8 +590,12 @@ export async function getStatusSummary( selectRecentSessionCandidates(allSessions, RECENT_SESSION_LIMIT), ); const totalSessions = allSessions.length; + const hostDesktop = await ( + await import("../gateway/desktop/host-source.js") + ).inspectHostDesktop({ config: cfg.desktop?.host }); const summary: StatusSummary = { runtimeVersion: resolveRuntimeServiceVersion(process.env), + hostDesktop: hostDesktop.status, linkChannel: linkContext ? { id: linkContext.plugin.id, diff --git a/src/status/types.ts b/src/status/types.ts index 83123d738504..d68988005da2 100644 --- a/src/status/types.ts +++ b/src/status/types.ts @@ -54,6 +54,7 @@ export type HeartbeatStatus = { /** Aggregate status summary before text or JSON formatting. */ export type StatusSummary = { runtimeVersion?: string | null; + hostDesktop?: import("../gateway/desktop/host-source.js").HostDesktopStatus; eventLoop?: import("../gateway/server/event-loop-health.js").GatewayEventLoopHealth; linkChannel?: { id: ChannelId; diff --git a/test/scripts/check-env-var-count.test.ts b/test/scripts/check-env-var-count.test.ts index d01ef8588c5b..e3ffc18d25d2 100644 --- a/test/scripts/check-env-var-count.test.ts +++ b/test/scripts/check-env-var-count.test.ts @@ -73,6 +73,40 @@ describe("check-env-var-count", () => { expect(() => main(["--base", "missing"], root)).toThrow(/Could not resolve/u); }); + it("still checks the budget when the base shares no reachable ancestor", () => { + // Shallow clones and grafted agent checkouts resolve origin/main but truncate the + // history behind it, which used to fail the whole changed-file gate. + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-shallow-")); + tempDirs.push(root); + const git = (...args: string[]) => + execFileSync( + "git", + ["-c", "user.name=OpenClaw", "-c", "user.email=test@openclaw.local", ...args], + { cwd: root, stdio: "ignore" }, + ); + fs.mkdirSync(path.join(root, "config"), { recursive: true }); + fs.mkdirSync(path.join(root, "src"), { recursive: true }); + fs.writeFileSync(path.join(root, "config/env-var-count-budget.txt"), "1\n"); + fs.writeFileSync(path.join(root, "src/runtime.ts"), "process.env.OPENCLAW_ONLY;\n"); + git("init"); + git("add", "."); + git("commit", "-m", "detached base"); + // Name the base explicitly; init.defaultBranch varies by environment. + git("branch", "-M", "severed-base"); + git("checkout", "--orphan", "severed"); + git("add", "."); + git("commit", "-m", "severed history"); + + expect(() => main(["--base", "severed-base"], root)).not.toThrow(); + + // The absolute budget check must still run without a baseline. + fs.writeFileSync( + path.join(root, "src/runtime.ts"), + "process.env.OPENCLAW_ONE; process.env.OPENCLAW_TWO;\n", + ); + expect(() => main(["--base", "severed-base"], root)).toThrow(/exceeds budget/u); + }); + it("compares against the fork budget when the base branch later shrinks", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-fork-")); tempDirs.push(root); diff --git a/ui/src/app/app-host.dock-suppression.test.ts b/ui/src/app/app-host.dock-suppression.test.ts index f225722147a4..b0b71df0c45a 100644 --- a/ui/src/app/app-host.dock-suppression.test.ts +++ b/ui/src/app/app-host.dock-suppression.test.ts @@ -23,7 +23,7 @@ afterEach(() => { }); describe("OpenClaw shell dock suppression", () => { - it("applies route and session ownership to shell panels", () => { + it("applies route ownership to shell panels without session-gating desktop", () => { vi.stubGlobal("localStorage", createStorageMock()); vi.stubGlobal( "matchMedia", @@ -41,12 +41,7 @@ describe("OpenClaw shell dock suppression", () => { hello: { auth: { role: "operator", scopes: ["operator.admin"] }, features: { - methods: [ - "terminal.open", - "browser.request", - "openclaw.chat", - "worker.desktop.observe", - ], + methods: ["terminal.open", "browser.request", "openclaw.chat", "desktop.observe"], }, }, lastError: null, @@ -157,7 +152,7 @@ describe("OpenClaw shell dock suppression", () => { } ).suppressed, ).toBe(false); - expect(desktopAvailable()).toBe(false); + expect(desktopAvailable()).toBe(true); context.sessions.state.result!.sessions = [ { @@ -174,10 +169,10 @@ describe("OpenClaw shell dock suppression", () => { { key: "agent:main:main", kind: "direct", updatedAt: 0 }, ]; renderLit(shell.render(), container); - expect(desktopAvailable()).toBe(false); + expect(desktopAvailable()).toBe(true); context.sessions.state.result = null; renderLit(shell.render(), container); - expect(desktopAvailable()).toBe(false); + expect(desktopAvailable()).toBe(true); }); }); diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index e38a624153e7..c6da27c0b722 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -28,7 +28,6 @@ import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts"; import { createIdleImport } from "../lib/idle-import.ts"; import { isWorkboardEnabledInConfigSnapshot } from "../lib/plugin-activation.ts"; import { resolveSessionDisplayName } from "../lib/session-display.ts"; -import { findUiSessionRow } from "../lib/sessions/route-navigation.ts"; import { isUiGlobalSessionKey, normalizeAgentId, @@ -526,8 +525,7 @@ class OpenClawShell } const gatewaySnapshot = context.gateway?.snapshot; if (gatewaySnapshot) { - const activeSessionRow = findUiSessionRow(context, this.activeSessionKey); - const desktopAvailable = isDesktopPanelAvailable(gatewaySnapshot, activeSessionRow); + const desktopAvailable = isDesktopPanelAvailable(gatewaySnapshot); if (this.commandPalette) { this.commandPalette.desktopAvailable = desktopAvailable; } diff --git a/ui/src/app/app-shell-chrome.ts b/ui/src/app/app-shell-chrome.ts index cc04df3ab051..d8980a7a553c 100644 --- a/ui/src/app/app-shell-chrome.ts +++ b/ui/src/app/app-shell-chrome.ts @@ -1,5 +1,3 @@ -import { isCloudWorkerPlacementState } from "../../../packages/gateway-protocol/src/schema/session-placement-state.js"; -import type { GatewaySessionRow } from "../api/types.ts"; import { isSettingsNavigationRoute } from "../app-navigation.ts"; import { routeIdFromPath, type RouteId } from "../app-route-paths.ts"; import { @@ -24,7 +22,6 @@ import type { BoardFace } from "../lib/board/settings.ts"; import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts"; import { resolveAsciiShortcutKey } from "../lib/keyboard-shortcuts.ts"; import { readSessionMethodAccess } from "../lib/session-method-access.ts"; -import { findUiSessionRow } from "../lib/sessions/route-navigation.ts"; import { isTerminalAvailable } from "../lib/terminal-availability.ts"; import type { ShellRouteState } from "./app-host-route-state.ts"; import type { ApplicationContext, ApplicationNavigationOptions } from "./context.ts"; @@ -58,13 +55,11 @@ export function isBrowserPanelAvailable( export function isDesktopPanelAvailable( snapshot: ApplicationContext["gateway"]["snapshot"], - session: GatewaySessionRow | undefined, ): boolean { return ( - isCloudWorkerPlacementState(session?.placement?.state) && snapshot.phase === "connected" && hasOperatorAdminAccess(snapshot.hello?.auth ?? null) && - isGatewayMethodAdvertised(snapshot, "worker.desktop.observe") === true + isGatewayMethodAdvertised(snapshot, "desktop.observe") === true ); } @@ -508,8 +503,7 @@ export class ShellChromeOwner { readonly handleDeferredDesktopToggle = (event: Event): void => { const host = this.host; const context = host.context; - const session = context ? findUiSessionRow(context, host.activeSessionKey) : undefined; - if (!context || !isDesktopPanelAvailable(context.gateway.snapshot, session)) { + if (!context || !isDesktopPanelAvailable(context.gateway.snapshot)) { event.stopImmediatePropagation(); return; } diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts index 45a17945dde4..a74bb733cf34 100644 --- a/ui/src/app/app-shell-view.ts +++ b/ui/src/app/app-shell-view.ts @@ -12,7 +12,6 @@ import type { ThemeModeChangeDetail } from "../components/theme-mode-toggle.ts"; import { t } from "../i18n/index.ts"; import { canCallGatewayMethod, isGatewayMethodAdvertised } from "../lib/gateway-methods.ts"; import { readSessionMethodAccess } from "../lib/session-method-access.ts"; -import { findUiSessionRow } from "../lib/sessions/route-navigation.ts"; import { normalizeAgentId } from "../lib/sessions/session-key.ts"; import { isTerminalAvailable } from "../lib/terminal-availability.ts"; import { findSettingsSearchBlocks } from "../pages/config/settings-search.ts"; @@ -152,8 +151,7 @@ export function renderApplicationShell(host: ShellViewHost) { context.config.current.terminalEnabled ?? false, ); const browserPanelAvailable = isBrowserPanelAvailable(gatewaySnapshot); - const activeSessionRow = findUiSessionRow(context, host.activeSessionKey); - const desktopPanelAvailable = isDesktopPanelAvailable(gatewaySnapshot, activeSessionRow); + const desktopPanelAvailable = isDesktopPanelAvailable(gatewaySnapshot); const custodianPanelAvailable = gatewayConnected && isGatewayMethodAdvertised(gatewaySnapshot, "openclaw.chat") === true; const activeRoute = host.routeState.routeId ?? "chat"; diff --git a/ui/src/components/desktop/desktop-client.test.ts b/ui/src/components/desktop/desktop-client.test.ts index 535dac9a444b..bc0e4a81a547 100644 --- a/ui/src/components/desktop/desktop-client.test.ts +++ b/ui/src/components/desktop/desktop-client.test.ts @@ -26,7 +26,7 @@ function createFakeRfb() { constructor( readonly target: HTMLElement, readonly channel: string | WebSocket, - readonly options?: { credentials?: { password: string } }, + readonly options?: { credentials?: { username?: string; password?: string } }, ) { super(); instances.push(this); @@ -52,7 +52,7 @@ describe("DesktopClient", () => { await client.connect({ gatewayUrl, wsUrl: "/desktop/observe?token=abc", - password: "secret", + credentials: { password: "secret" }, viewOnly: true, target, }); @@ -70,7 +70,7 @@ describe("DesktopClient", () => { const handle = await client.connect({ gatewayUrl: "ws://control.example.test", wsUrl: "/desktop/observe", - password: "secret", + credentials: { username: "operator", password: "secret" }, background: "rgb(8, 8, 8)", viewOnly: false, target: document.createElement("div"), @@ -79,7 +79,9 @@ describe("DesktopClient", () => { expect(instances[0]?.background).toBe("rgb(8, 8, 8)"); expect(instances[0]?.viewOnly).toBe(false); expect(instances[0]?.scaleViewport).toBe(true); - expect(instances[0]?.options).toEqual({ credentials: { password: "secret" } }); + expect(instances[0]?.options).toEqual({ + credentials: { username: "operator", password: "secret" }, + }); handle.disconnect(); expect(instances[0]?.disconnect).toHaveBeenCalledOnce(); diff --git a/ui/src/components/desktop/desktop-client.ts b/ui/src/components/desktop/desktop-client.ts index 25d77127b51b..8a21f324ff82 100644 --- a/ui/src/components/desktop/desktop-client.ts +++ b/ui/src/components/desktop/desktop-client.ts @@ -10,11 +10,11 @@ type DesktopSecurityFailureDetail = { type DesktopConnectOptions = { background?: string; + credentials?: { username?: string; password?: string }; gatewayUrl?: string; onConnect?: () => void; onDisconnect?: (detail: DesktopDisconnectDetail) => void; onSecurityFailure?: (detail: DesktopSecurityFailureDetail) => void; - password?: string; target: HTMLElement; viewOnly: boolean; wsUrl: string; @@ -34,7 +34,7 @@ type RfbClient = EventTarget & { type RfbConstructor = new ( target: HTMLElement, channel: string | WebSocket, - options?: { credentials?: { password: string } }, + options?: { credentials?: { username?: string; password?: string } }, ) => RfbClient; type RfbLoader = () => Promise; @@ -85,7 +85,7 @@ export class DesktopClient { const rfb = new Rfb( options.target, socket, - options.password ? { credentials: { password: options.password } } : undefined, + options.credentials ? { credentials: options.credentials } : undefined, ); rfb.background = options.background ?? getComputedStyle(options.target).backgroundColor; rfb.viewOnly = options.viewOnly; diff --git a/ui/src/components/desktop/desktop-panel-credentials.ts b/ui/src/components/desktop/desktop-panel-credentials.ts new file mode 100644 index 000000000000..12c0e060c889 --- /dev/null +++ b/ui/src/components/desktop/desktop-panel-credentials.ts @@ -0,0 +1,19 @@ +const DESKTOP_CREDENTIALS_REQUIRED_CODE = "DESKTOP_CREDENTIALS_REQUIRED"; + +/** Reads the host-observe retry contract without exposing credential material. */ +export function desktopCredentialRequirement( + error: unknown, +): "vnc-password" | "ard-account" | null { + if (!error || typeof error !== "object" || !("details" in error)) { + return null; + } + const details = error.details; + if (!details || typeof details !== "object") { + return null; + } + if (!("code" in details) || details.code !== DESKTOP_CREDENTIALS_REQUIRED_CODE) { + return null; + } + const auth = "auth" in details ? details.auth : undefined; + return auth === "vnc-password" || auth === "ard-account" ? auth : null; +} diff --git a/ui/src/components/desktop/desktop-panel-styles.ts b/ui/src/components/desktop/desktop-panel-styles.ts new file mode 100644 index 000000000000..46b9cd12d39b --- /dev/null +++ b/ui/src/components/desktop/desktop-panel-styles.ts @@ -0,0 +1,164 @@ +import { css } from "lit"; + +export const desktopPanelStyles = css` + .bp--bottom { + left: var(--shell-nav-width, 0); + right: calc(var(--oc-terminal-reserve-right, 0px) + var(--oc-browser-reserve-right, 0px)); + bottom: calc(var(--oc-terminal-reserve-bottom, 0px) + var(--oc-browser-reserve-bottom, 0px)); + } + .bp--right { + top: var(--shell-topbar-height, 0); + right: calc(var(--oc-terminal-reserve-right, 0px) + var(--oc-browser-reserve-right, 0px)); + bottom: calc(var(--oc-terminal-reserve-bottom, 0px) + var(--oc-browser-reserve-bottom, 0px)); + } + .bp-title { + min-width: 0; + padding-left: 8px; + font-size: 13px; + font-weight: 600; + } + .bp-icon.is-active { + color: var(--accent, #ff5c5c); + background: color-mix(in srgb, var(--accent, #ff5c5c) 14%, transparent); + } + .desktop-content { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + } + .desktop-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid var(--border, #262b34); + } + .desktop-toolbar--connection { + min-height: 42px; + gap: 12px; + } + .desktop-toolbar__spacer { + flex: 1; + } + .desktop-button { + border: 1px solid var(--border, #262b34); + border-radius: 6px; + padding: 5px 10px; + background: transparent; + color: var(--text, #d7dae0); + font: inherit; + font-size: 12px; + } + .desktop-button:hover:not(:disabled) { + background: color-mix(in srgb, var(--text, #d7dae0) 10%, transparent); + } + .desktop-button--primary { + border-color: var(--accent, #ff5c5c); + color: var(--accent, #ff5c5c); + } + .desktop-button:disabled { + opacity: 0.5; + } + .desktop-session { + overflow: hidden; + max-width: 100%; + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; + } + .desktop-note { + padding: 7px 12px; + border-bottom: 1px solid var(--border, #262b34); + color: var(--muted, #8a919e); + font-size: 12px; + } + .desktop-note--error { + color: var(--danger, #ff6b6b); + } + .desktop-picker, + .desktop-status { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + gap: 10px; + overflow: auto; + padding: 14px; + background: var(--panel); + } + .desktop-status { + align-items: center; + justify-content: center; + text-align: center; + color: var(--muted, #8a919e); + } + .desktop-credentials { + display: flex; + width: min(320px, 100%); + flex-direction: column; + gap: 10px; + text-align: left; + } + .desktop-credentials__label { + display: flex; + flex-direction: column; + gap: 5px; + color: var(--text, #d7dae0); + font-size: 12px; + } + .desktop-credentials__input { + border: 1px solid var(--border, #262b34); + border-radius: 6px; + padding: 7px 9px; + background: var(--bg, #111318); + color: var(--text, #d7dae0); + font: inherit; + } + .desktop-environment { + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + border: 1px solid var(--border, #262b34); + border-radius: 8px; + } + .desktop-environment__details { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 5px; + } + .desktop-environment__id { + overflow: hidden; + color: var(--text, #d7dae0); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + } + .desktop-environment__meta, + .desktop-environment__sessions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 5px; + color: var(--muted, #8a919e); + font-size: 11px; + } + .desktop-stage { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; + background: var(--bg); + } + .desktop-surface { + position: absolute; + inset: 0; + background: var(--bg); + } +`; diff --git a/ui/src/components/desktop/desktop-panel.ts b/ui/src/components/desktop/desktop-panel.ts index f254366785d9..7d8e738bf703 100644 --- a/ui/src/components/desktop/desktop-panel.ts +++ b/ui/src/components/desktop/desktop-panel.ts @@ -1,11 +1,12 @@ import type { + DesktopObserveResult, + DesktopSource, EnvironmentSummary, EnvironmentsListResult, WorkerDesktopAppId, WorkerDesktopLaunchResult, - WorkerDesktopObserveResult, } from "@openclaw/gateway-protocol"; -import { css, html, nothing, svg } from "lit"; +import { html, nothing, svg } from "lit"; import { property, state } from "lit/decorators.js"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import { t } from "../../i18n/index.ts"; @@ -20,7 +21,9 @@ import { } from "../panel-toggle-contract.ts"; import { desktopAppIcon, desktopAppLabel } from "./desktop-app-presentation.ts"; import { DesktopClient, type DesktopConnectionHandle } from "./desktop-client.ts"; +import { desktopCredentialRequirement } from "./desktop-panel-credentials.ts"; import { desktopPanelLauncherStyles } from "./desktop-panel-launcher-styles.ts"; +import { desktopPanelStyles } from "./desktop-panel-styles.ts"; const CLOSE_GLYPH = svg``; const DOCK_BOTTOM_GLYPH = svg``; @@ -35,11 +38,24 @@ const panelLayout = createDockPanelLayout({ defaultHeight: 420, defaultWidth: 560, }); - -type DesktopPanelState = "picker" | "connecting" | "connected" | "disconnected"; +type DesktopPanelState = "picker" | "credentials" | "connecting" | "connected" | "disconnected"; type DesktopAppId = WorkerDesktopAppId; +type DesktopCredentials = { username?: string; password?: string }; +type PendingDesktopConnection = { + environmentId: string; + control: boolean; + observed?: DesktopObserveResult; + operationId: number; +}; +type ObservedDesktopConnection = PendingDesktopConnection & { observed: DesktopObserveResult }; -/** `` — dockable RFB access to cloud-worker desktops. */ +function desktopSourceForEnvironment(environment: Pick): DesktopSource { + return environment.id === "gateway" + ? { kind: "host" } + : { kind: "environment", environmentId: environment.id }; +} + +/** `` — dockable RFB access to Gateway desktop sources. */ class OpenClawDesktopPanel extends OpenClawLitElement { @property({ attribute: false }) client: GatewayBrowserClient | null = null; @property({ type: Boolean }) available = false; @@ -52,6 +68,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement { @state() private loading = false; @state() private state: DesktopPanelState = "picker"; @state() private environmentId: string | null = null; + @state() private source: DesktopSource | null = null; @state() private controlling = false; @state() private errorText: string | null = null; @state() private noticeText: string | null = null; @@ -61,6 +78,9 @@ class OpenClawDesktopPanel extends OpenClawLitElement { @state() private desktopApps: DesktopAppId[] = []; private connection: DesktopConnectionHandle | null = null; + private credentials: DesktopCredentials | undefined; + private credentialAuth: "vnc-password" | "ard-account" | undefined; + private pendingConnection: PendingDesktopConnection | null = null; private operationId = 0; private launchOperationId = 0; private controlTakeoverRecoveryUsed = false; @@ -71,152 +91,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement { }); private readonly onToggleRequest = (event: Event) => this.handleToggleRequest(event); - static override styles = [ - dockPanelStyles, - desktopPanelLauncherStyles, - css` - .bp--bottom { - left: var(--shell-nav-width, 0); - right: calc(var(--oc-terminal-reserve-right, 0px) + var(--oc-browser-reserve-right, 0px)); - bottom: calc( - var(--oc-terminal-reserve-bottom, 0px) + var(--oc-browser-reserve-bottom, 0px) - ); - } - .bp--right { - top: var(--shell-topbar-height, 0); - right: calc(var(--oc-terminal-reserve-right, 0px) + var(--oc-browser-reserve-right, 0px)); - bottom: var(--oc-terminal-reserve-bottom, 0px); - } - .bp-title { - min-width: 0; - padding-left: 8px; - font-size: 13px; - font-weight: 600; - } - .bp-icon.is-active { - color: var(--accent, #ff5c5c); - background: color-mix(in srgb, var(--accent, #ff5c5c) 14%, transparent); - } - .desktop-content { - display: flex; - flex: 1; - min-height: 0; - flex-direction: column; - } - .desktop-toolbar { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 10px; - border-bottom: 1px solid var(--border, #262b34); - } - .desktop-toolbar--connection { - min-height: 42px; - gap: 12px; - } - .desktop-toolbar__spacer { - flex: 1; - } - .desktop-button { - border: 1px solid var(--border, #262b34); - border-radius: 6px; - padding: 5px 10px; - background: transparent; - color: var(--text, #d7dae0); - font: inherit; - font-size: 12px; - } - .desktop-button:hover:not(:disabled) { - background: color-mix(in srgb, var(--text, #d7dae0) 10%, transparent); - } - .desktop-button--primary { - border-color: var(--accent, #ff5c5c); - color: var(--accent, #ff5c5c); - } - .desktop-button:disabled { - opacity: 0.5; - } - .desktop-session { - overflow: hidden; - max-width: 100%; - color: var(--muted); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 11px; - text-overflow: ellipsis; - white-space: nowrap; - } - .desktop-note { - padding: 7px 12px; - border-bottom: 1px solid var(--border, #262b34); - color: var(--muted, #8a919e); - font-size: 12px; - } - .desktop-note--error { - color: var(--danger, #ff6b6b); - } - .desktop-picker, - .desktop-status { - display: flex; - flex: 1; - min-height: 0; - flex-direction: column; - gap: 10px; - overflow: auto; - padding: 14px; - background: var(--panel); - } - .desktop-status { - align-items: center; - justify-content: center; - text-align: center; - color: var(--muted, #8a919e); - } - .desktop-environment { - display: flex; - align-items: center; - gap: 10px; - padding: 10px; - border: 1px solid var(--border, #262b34); - border-radius: 8px; - } - .desktop-environment__details { - display: flex; - flex: 1; - min-width: 0; - flex-direction: column; - gap: 5px; - } - .desktop-environment__id { - overflow: hidden; - color: var(--text, #d7dae0); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; - } - .desktop-environment__meta, - .desktop-environment__sessions { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 5px; - color: var(--muted, #8a919e); - font-size: 11px; - } - .desktop-stage { - position: relative; - flex: 1; - min-height: 0; - overflow: hidden; - background: var(--bg); - } - .desktop-surface { - position: absolute; - inset: 0; - background: var(--bg); - } - `, - ]; + static override styles = [dockPanelStyles, desktopPanelLauncherStyles, desktopPanelStyles]; override connectedCallback(): void { super.connectedCallback(); @@ -230,6 +105,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement { override disconnectedCallback(): void { window.removeEventListener(DESKTOP_PANEL_TOGGLE_EVENT, this.onToggleRequest); this.disconnectConnection(); + this.credentials = undefined; super.disconnectedCallback(); } @@ -289,6 +165,9 @@ class OpenClawDesktopPanel extends OpenClawLitElement { this.clearLaunchState(); this.state = "picker"; this.environmentId = null; + this.source = null; + this.credentials = undefined; + this.credentialAuth = undefined; this.desktopApps = []; this.controlling = false; this.disconnectedReason = null; @@ -296,6 +175,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement { private disconnectConnection(): void { this.operationId += 1; + this.pendingConnection = null; const connection = this.connection; this.connection = null; connection?.disconnect(); @@ -320,9 +200,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement { if (operationId !== this.operationId) { return; } - this.environments = result.environments.filter( - (environment) => environment.worker?.desktop === true, - ); + this.environments = result.environments.filter((environment) => environment.desktop === true); } catch (error) { if (operationId === this.operationId) { this.errorText = t("desktop.errors.listFailed", { error: formatUiError(error) }); @@ -345,6 +223,8 @@ class OpenClawDesktopPanel extends OpenClawLitElement { } if (this.environmentId !== environmentId) { this.clearLaunchState(); + this.credentials = undefined; + this.credentialAuth = undefined; this.desktopApps = [ ...(this.environments.find((environment) => environment.id === environmentId)?.worker ?.desktopApps ?? []), @@ -352,7 +232,12 @@ class OpenClawDesktopPanel extends OpenClawLitElement { } this.disconnectConnection(); const operationId = this.operationId; + const environment = this.environments.find((candidate) => candidate.id === environmentId) ?? { + id: environmentId, + }; + const source = desktopSourceForEnvironment(environment); this.environmentId = environmentId; + this.source = source; this.controlling = control; this.state = "connecting"; this.errorText = null; @@ -362,13 +247,61 @@ class OpenClawDesktopPanel extends OpenClawLitElement { } this.controlTakeoverRecoveryUsed = options.takeoverRecovery === true; try { - const observed = await client.request("worker.desktop.observe", { - environmentId, + const observeCredentials = + source.kind === "host" && + this.credentials?.password && + (this.credentialAuth === "vnc-password" || + (this.credentialAuth === "ard-account" && this.credentials.username)) + ? this.credentials + : undefined; + const observed = await client.request("desktop.observe", { + source, control, + ...(observeCredentials ? { credentials: observeCredentials } : {}), }); if (operationId !== this.operationId) { return; } + const credentials = observed.vncPassword + ? { password: observed.vncPassword } + : observed.auth === "vnc-password" + ? this.credentials + : undefined; + if (observed.auth === "vnc-password" && !credentials?.password) { + this.credentialAuth = "vnc-password"; + this.pendingConnection = { environmentId, control, observed, operationId }; + this.state = "credentials"; + return; + } + if (observed.auth === "ard-account") { + this.credentialAuth = "ard-account"; + } + await this.connectObserved( + { environmentId, control, observed, operationId }, + observed.auth === "vnc-password" ? credentials : undefined, + ); + } catch (error) { + const requiredAuth = desktopCredentialRequirement(error); + if (requiredAuth && operationId === this.operationId) { + this.credentialAuth = requiredAuth; + this.pendingConnection = { environmentId, control, operationId }; + this.state = "credentials"; + return; + } + this.failConnection(operationId, error); + } + } + + private async connectObserved( + pending: ObservedDesktopConnection, + credentials?: DesktopCredentials, + ): Promise { + const client = this.client; + if (!client || pending.operationId !== this.operationId) { + return; + } + this.state = "connecting"; + try { await this.updateComplete; const target = this.shadowRoot?.querySelector(".desktop-surface"); if (!target) { @@ -378,46 +311,97 @@ class OpenClawDesktopPanel extends OpenClawLitElement { const background = getComputedStyle(target).backgroundColor; const connection = await desktopClient.connect({ background, - wsUrl: observed.wsPath, + wsUrl: pending.observed.wsPath, gatewayUrl: client.gatewayUrl, - password: observed.vncPassword, - viewOnly: !observed.control, + credentials, + viewOnly: !pending.observed.control, target, onConnect: () => { - if (operationId === this.operationId) { + if (pending.operationId === this.operationId) { this.state = "connected"; } }, onDisconnect: (detail) => { - if (operationId === this.operationId) { - this.handleDesktopDisconnect(environmentId, detail.code, detail.reason); + if (pending.operationId === this.operationId) { + this.handleDesktopDisconnect(pending.environmentId, detail.code, detail.reason); } }, onSecurityFailure: (detail) => { - if (operationId === this.operationId) { + if (pending.operationId === this.operationId) { this.errorText = t("desktop.errors.securityFailed", { reason: detail.reason ?? t("desktop.unknownReason"), }); } }, }); - if (operationId !== this.operationId) { + if (pending.operationId !== this.operationId) { connection.disconnect(); return; } this.connection = connection; } catch (error) { - if (operationId === this.operationId) { - this.state = "disconnected"; - this.disconnectedReason = formatUiError(error); - this.clearLaunchState(); - } + this.failConnection(pending.operationId, error); + } + } + + private failConnection(operationId: number, error: unknown): void { + if (operationId !== this.operationId) { + return; + } + this.state = "disconnected"; + this.disconnectedReason = formatUiError(error); + this.clearLaunchState(); + } + + private handleCredentialsSubmit(event: SubmitEvent): void { + event.preventDefault(); + const pending = this.pendingConnection; + if (!pending || pending.operationId !== this.operationId) { + return; + } + const formData = new FormData(event.currentTarget as HTMLFormElement); + const password = formData.get("password"); + if (typeof password !== "string" || password.length === 0) { + return; + } + const username = formData.get("username"); + if ( + this.credentialAuth === "ard-account" && + (typeof username !== "string" || username.trim().length === 0) + ) { + return; + } + const credentials = { + ...(typeof username === "string" && username.trim() ? { username: username.trim() } : {}), + password, + }; + this.credentials = credentials; + this.pendingConnection = null; + if (pending.observed) { + void this.connectObserved({ ...pending, observed: pending.observed }, credentials); + } else { + void this.connectEnvironment(pending.environmentId, pending.control); } } private handleDesktopDisconnect(environmentId: string, code?: number, reason?: string): void { this.connection = null; this.clearLaunchState(); + if (code === 1008 && this.credentialAuth === "ard-account") { + this.credentials = this.credentials?.username + ? { username: this.credentials.username } + : undefined; + this.pendingConnection = { + environmentId, + control: this.controlling, + operationId: this.operationId, + }; + this.state = "credentials"; + this.errorText = t("desktop.errors.securityFailed", { + reason: reason || t("desktop.unknownReason"), + }); + return; + } if ( code === 4000 && reason === "control-taken" && @@ -438,10 +422,10 @@ class OpenClawDesktopPanel extends OpenClawLitElement { private async launchApp(app: DesktopAppId): Promise { const client = this.client; - const environmentId = this.environmentId; + const source = this.source; if ( !client || - !environmentId || + source?.kind !== "environment" || (this.state !== "connecting" && this.state !== "connected") || !this.desktopApps.includes(app) || this.launchingApp === app @@ -452,16 +436,16 @@ class OpenClawDesktopPanel extends OpenClawLitElement { this.launchingApp = app; this.launchErrorText = null; try { - await client.request("worker.desktop.launch", { - environmentId, + await client.request("desktop.launch", { + source, app, }); - if (operationId !== this.launchOperationId || environmentId !== this.environmentId) { + if (operationId !== this.launchOperationId || source !== this.source) { return; } this.launchingApp = null; } catch (error) { - if (operationId !== this.launchOperationId || environmentId !== this.environmentId) { + if (operationId !== this.launchOperationId || source !== this.source) { return; } this.launchingApp = null; @@ -533,10 +517,13 @@ class OpenClawDesktopPanel extends OpenClawLitElement { private renderEnvironment(environment: EnvironmentSummary) { const worker = environment.worker; + const source = desktopSourceForEnvironment(environment); return html`
-
${environment.id}
+
+ ${source.kind === "host" ? t("desktop.thisMachine") : environment.id} +
${worker?.state ?? environment.status}
@@ -562,7 +549,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement { private renderConnection() { return html`
- ${this.desktopApps.length > 0 + ${this.source?.kind === "environment" && this.desktopApps.length > 0 ? html`
${this.desktopApps.map((app) => { const launching = this.launchingApp === app; @@ -652,6 +639,46 @@ class OpenClawDesktopPanel extends OpenClawLitElement { `; } + private renderCredentials() { + const ardAccount = this.credentialAuth === "ard-account"; + return html` +
+
this.handleCredentialsSubmit(event)} + > +
${t(ardAccount ? "desktop.accountPrompt" : "desktop.passwordPrompt")}
+ ${ardAccount + ? html`` + : nothing} + + +
+
+ `; + } + override render() { if (!this.available || !this.dockLayout.open) { return nothing; @@ -673,9 +700,11 @@ class OpenClawDesktopPanel extends OpenClawLitElement { : nothing} ${this.state === "picker" ? this.renderPicker() - : this.state === "disconnected" - ? this.renderDisconnected() - : this.renderConnection()} + : this.state === "credentials" + ? this.renderCredentials() + : this.state === "disconnected" + ? this.renderDisconnected() + : this.renderConnection()}
`; diff --git a/ui/src/e2e/desktop-panel.e2e.test.ts b/ui/src/e2e/desktop-panel.e2e.test.ts index d23c872b5fb4..794664217408 100644 --- a/ui/src/e2e/desktop-panel.e2e.test.ts +++ b/ui/src/e2e/desktop-panel.e2e.test.ts @@ -3,7 +3,7 @@ import { installMockGateway } from "../test-helpers/control-ui-e2e.ts"; import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; const suite = createControlUiE2eSuite({ - name: "cloud worker desktop panel", + name: "desktop source panel", startServerBeforeBrowser: true, unavailableMessage: (executablePath) => `Playwright Chromium is not installed or cannot start at ${executablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`.`, @@ -48,11 +48,15 @@ async function installDesktopClientFake(panel: import("playwright").Locator) { ( element as HTMLElement & { desktopClientFactory: () => { - connect(): Promise<{ disconnect(): void }>; + connect(options: { credentials?: { username?: string; password?: string } }): Promise<{ + disconnect(): void; + }>; }; } ).desktopClientFactory = () => ({ - async connect() { + async connect(options) { + element.dataset.connectCount = String(Number(element.dataset.connectCount ?? "0") + 1); + element.dataset.usedCredentials = options.credentials?.password ? "true" : "false"; return { disconnect() { element.dataset.disconnectCount = String( @@ -73,7 +77,7 @@ suite.define(() => { methodResponses: { "sessions.list": sessionsList("active") }, }, { - featureMethods: ["environments.list", "worker.desktop.observe"], + featureMethods: ["environments.list", "desktop.observe"], methodResponses: { "sessions.list": sessionsList("active") }, operatorScopes: ["operator.read"], }, @@ -87,34 +91,190 @@ suite.define(() => { } }); - it("keeps the desktop command and panel unavailable for a local session", async () => { + it("keeps the desktop command and panel available without a cloud session", async () => { await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => { const gateway = await installMockGateway(page, { - featureMethods: ["environments.list", "worker.desktop.observe"], - methodResponses: { "sessions.list": sessionsList("local") }, + featureMethods: ["environments.list", "desktop.observe"], + methodResponses: { + "sessions.list": sessionsList("local"), + "environments.list": { environments: [] }, + }, }); await page.goto(`${suite.server.baseUrl}chat`); await openPalette(page); - expect(await page.getByRole("option", { name: "Desktop", exact: true }).count()).toBe(0); + expect(await page.getByRole("option", { name: "Desktop", exact: true }).count()).toBe(1); - await page.evaluate(() => { - window.dispatchEvent( - new CustomEvent("openclaw:desktop-toggle", { detail: { open: true } }), - ); + await page.getByRole("option", { name: "Desktop", exact: true }).click(); + await page.locator("openclaw-desktop-panel section[aria-label='Desktop']").waitFor(); + await gateway.waitForRequest("environments.list"); + }); + }); + + it("keeps a right-docked desktop above bottom-docked panels", async () => { + await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => { + await installMockGateway(page, { + featureMethods: ["environments.list", "desktop.observe"], + methodResponses: { + "sessions.list": sessionsList("local"), + "environments.list": { environments: [] }, + }, + }); + const panel = await openDesktopPanel(page); + await panel.getByRole("button", { name: "Dock to right", exact: true }).click(); + const bottom = await panel.evaluate((element) => { + document.documentElement.style.setProperty("--oc-terminal-reserve-bottom", "40px"); + document.documentElement.style.setProperty("--oc-browser-reserve-bottom", "80px"); + const section = element.shadowRoot?.querySelector(".bp--right"); + return section ? getComputedStyle(section).bottom : null; + }); + expect(bottom).toBe("120px"); + }); + }); + + it("connects the host source after an in-memory VNC password prompt", async () => { + await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => { + const gateway = await installMockGateway(page, { + featureMethods: ["desktop.observe", "environments.list"], + methodResponses: { + "sessions.list": sessionsList("local"), + "environments.list": { + environments: [ + { id: "gateway", type: "local", status: "available", desktop: true }, + { + id: "legacy-nested-worker", + type: "worker", + status: "available", + worker: { + providerId: "crabbox", + state: "ready", + ageMs: 1_000, + attachedSessionIds: [], + tunnelStatus: "connected", + desktop: true, + }, + }, + ], + }, + "desktop.observe": { + sequence: [ + { + __mockError: { + code: "INVALID_REQUEST", + message: "VNC password is required to observe this machine", + details: { + code: "DESKTOP_CREDENTIALS_REQUIRED", + auth: "vnc-password", + }, + }, + }, + { + transport: "rfb", + wsPath: "/desktop/observe?token=host", + expiresAtMs: 60_000, + control: false, + auth: "vnc-password", + }, + ], + }, + }, + }); + + const panel = await openDesktopPanel(page); + await gateway.waitForRequest("environments.list"); + await panel.getByText("This machine", { exact: true }).waitFor(); + expect(await panel.getByText("legacy-nested-worker", { exact: true }).count()).toBe(0); + await installDesktopClientFake(panel); + + await panel.getByRole("button", { name: "Connect", exact: true }).click(); + const observeRequest = await gateway.waitForRequest("desktop.observe"); + expect(observeRequest.params).toEqual({ source: { kind: "host" }, control: false }); + await panel.getByText("Enter the VNC password for this machine.", { exact: true }).waitFor(); + expect(await panel.getAttribute("data-connect-count")).toBeNull(); + + await panel.getByLabel("VNC password", { exact: true }).fill("memory-only-test-password"); + await panel.getByRole("button", { name: "Connect", exact: true }).click(); + await expect.poll(async () => await panel.getAttribute("data-connect-count")).toBe("1"); + expect(await panel.getAttribute("data-used-credentials")).toBe("true"); + expect(await panel.getByRole("button", { name: "Browser", exact: true }).count()).toBe(0); + expect(await panel.getByRole("button", { name: "Terminal", exact: true }).count()).toBe(0); + const observeRequests = await gateway.getRequests("desktop.observe"); + expect(observeRequests).toHaveLength(2); + expect(observeRequests[1]?.params).toEqual({ + source: { kind: "host" }, + control: false, + credentials: { password: "memory-only-test-password" }, + }); + expect(await gateway.getRequests("desktop.launch")).toHaveLength(0); + }); + }); + + it("retries host observe with ARD credentials without passing them to noVNC", async () => { + await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => { + const gateway = await installMockGateway(page, { + featureMethods: ["desktop.observe", "environments.list"], + methodResponses: { + "sessions.list": sessionsList("local"), + "environments.list": { + environments: [{ id: "gateway", type: "local", status: "available", desktop: true }], + }, + "desktop.observe": { + sequence: [ + { + __mockError: { + code: "INVALID_REQUEST", + message: "macOS account credentials are required to observe Screen Sharing", + details: { + code: "DESKTOP_CREDENTIALS_REQUIRED", + auth: "ard-account", + }, + }, + }, + { + transport: "rfb", + wsPath: "/desktop/observe?token=ard-host", + expiresAtMs: 60_000, + control: false, + auth: "ard-account", + }, + ], + }, + }, + }); + + const panel = await openDesktopPanel(page); + await gateway.waitForRequest("environments.list"); + await installDesktopClientFake(panel); + await panel.getByRole("button", { name: "Connect", exact: true }).click(); + await panel + .getByText("Enter a macOS account to authenticate Screen Sharing.", { exact: true }) + .waitFor(); + expect((await gateway.getRequests("desktop.observe"))[0]?.params).toEqual({ + source: { kind: "host" }, + control: false, + }); + + await panel.getByLabel("macOS username", { exact: true }).fill("operator"); + await panel + .getByLabel("macOS password", { exact: true }) + .fill("memory-only-account-password"); + await panel.getByRole("button", { name: "Connect", exact: true }).click(); + await expect.poll(async () => await panel.getAttribute("data-connect-count")).toBe("1"); + expect(await panel.getAttribute("data-used-credentials")).toBe("false"); + const requests = await gateway.getRequests("desktop.observe"); + expect(requests).toHaveLength(2); + expect(requests[1]?.params).toEqual({ + source: { kind: "host" }, + control: false, + credentials: { username: "operator", password: "memory-only-account-password" }, }); - await page.waitForTimeout(250); - expect( - await page.locator("openclaw-desktop-panel section[aria-label='Desktop']").count(), - ).toBe(0); - expect(await gateway.getRequests("environments.list")).toHaveLength(0); }); }); it("launches advertised desktop apps and keeps observe controls working", async () => { await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => { const gateway = await installMockGateway(page, { - deferredMethods: ["worker.desktop.launch"], - featureMethods: ["environments.list", "worker.desktop.launch", "worker.desktop.observe"], + deferredMethods: ["desktop.launch"], + featureMethods: ["desktop.launch", "desktop.observe", "environments.list"], methodResponses: { "sessions.list": sessionsList("active"), "environments.list": { @@ -123,22 +283,25 @@ suite.define(() => { id: "worker-desktop-1", type: "worker", status: "available", + desktop: true, worker: { providerId: "crabbox", state: "attached", ageMs: 1_000, attachedSessionIds: ["agent:main:desktop"], tunnelStatus: "connected", - desktop: true, desktopApps: ["browser", "terminal"], }, }, ], }, - "worker.desktop.observe": { + "desktop.observe": { cases: [ { - match: { environmentId: "worker-desktop-1", control: false }, + match: { + source: { kind: "environment", environmentId: "worker-desktop-1" }, + control: false, + }, response: { transport: "rfb", wsPath: "/desktop/observe?token=view", @@ -147,7 +310,10 @@ suite.define(() => { }, }, { - match: { environmentId: "worker-desktop-1", control: true }, + match: { + source: { kind: "environment", environmentId: "worker-desktop-1" }, + control: true, + }, response: { transport: "rfb", wsPath: "/desktop/observe?token=control", @@ -157,7 +323,7 @@ suite.define(() => { }, ], }, - "worker.desktop.launch": { app: "browser", status: "ready" }, + "desktop.launch": { app: "browser", status: "ready" }, }, }); @@ -168,8 +334,11 @@ suite.define(() => { await installDesktopClientFake(panel); await panel.getByRole("button", { name: "Connect", exact: true }).click(); - const viewRequest = await gateway.waitForRequest("worker.desktop.observe"); - expect(viewRequest.params).toEqual({ environmentId: "worker-desktop-1", control: false }); + const viewRequest = await gateway.waitForRequest("desktop.observe"); + expect(viewRequest.params).toEqual({ + source: { kind: "environment", environmentId: "worker-desktop-1" }, + control: false, + }); await panel.getByText("Connecting to desktop…", { exact: true }).waitFor(); await panel.getByRole("button", { name: "Browser", exact: true }).waitFor(); await panel.getByRole("button", { name: "Terminal", exact: true }).waitFor(); @@ -197,17 +366,20 @@ suite.define(() => { expect(stageUsesAppBackground).toBe(true); await browserButton.click(); - const launchRequest = await gateway.waitForRequest("worker.desktop.launch"); - expect(launchRequest.params).toEqual({ environmentId: "worker-desktop-1", app: "browser" }); + const launchRequest = await gateway.waitForRequest("desktop.launch"); + expect(launchRequest.params).toEqual({ + source: { kind: "environment", environmentId: "worker-desktop-1" }, + app: "browser", + }); await expect.poll(async () => await browserButton.getAttribute("aria-busy")).toBe("true"); expect(await terminalButton.isEnabled()).toBe(true); - await gateway.resolveDeferred("worker.desktop.launch", { app: "browser", status: "ready" }); + await gateway.resolveDeferred("desktop.launch", { app: "browser", status: "ready" }); await expect.poll(async () => await browserButton.getAttribute("aria-busy")).toBe("false"); - await gateway.deferNext("worker.desktop.launch"); + await gateway.deferNext("desktop.launch"); await browserButton.click(); - await gateway.waitForRequest("worker.desktop.launch"); - await gateway.rejectDeferred("worker.desktop.launch", { + await gateway.waitForRequest("desktop.launch"); + await gateway.rejectDeferred("desktop.launch", { message: "worker desktop app launch unavailable; try again", }); await panel @@ -218,24 +390,20 @@ suite.define(() => { expect(await browserButton.isEnabled()).toBe(true); await panel.getByRole("button", { name: "Disconnect", exact: true }).click(); - await panel.getByText("Cloud worker desktops", { exact: true }).waitFor(); + await panel.getByText("Desktop sources", { exact: true }).waitFor(); expect( await panel .getByText("worker desktop app launch unavailable; try again", { exact: true }) .count(), ).toBe(0); await panel.getByRole("button", { name: "Connect", exact: true }).click(); - await expect - .poll(async () => (await gateway.getRequests("worker.desktop.observe")).length) - .toBe(2); + await expect.poll(async () => (await gateway.getRequests("desktop.observe")).length).toBe(2); await panel.getByRole("button", { name: "Take control", exact: true }).click(); - await expect - .poll(async () => (await gateway.getRequests("worker.desktop.observe")).length) - .toBe(3); - const observeRequests = await gateway.getRequests("worker.desktop.observe"); + await expect.poll(async () => (await gateway.getRequests("desktop.observe")).length).toBe(3); + const observeRequests = await gateway.getRequests("desktop.observe"); expect(observeRequests[2]?.params).toEqual({ - environmentId: "worker-desktop-1", + source: { kind: "environment", environmentId: "worker-desktop-1" }, control: true, }); expect(await panel.getByRole("button", { name: "Take control", exact: true }).count()).toBe( @@ -243,7 +411,7 @@ suite.define(() => { ); await panel.getByRole("button", { name: "Disconnect", exact: true }).click(); - await panel.getByText("Cloud worker desktops", { exact: true }).waitFor(); + await panel.getByText("Desktop sources", { exact: true }).waitFor(); expect(Number((await panel.getAttribute("data-disconnect-count")) ?? "0")).toBeGreaterThan(0); }); }); @@ -251,7 +419,7 @@ suite.define(() => { it("shows only apps advertised by the selected environment", async () => { await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => { const gateway = await installMockGateway(page, { - featureMethods: ["environments.list", "worker.desktop.launch", "worker.desktop.observe"], + featureMethods: ["desktop.launch", "desktop.observe", "environments.list"], methodResponses: { "sessions.list": sessionsList("active"), "environments.list": { @@ -260,19 +428,19 @@ suite.define(() => { id: "terminal-only-worker", type: "worker", status: "available", + desktop: true, worker: { providerId: "crabbox", state: "ready", ageMs: 1_000, attachedSessionIds: [], tunnelStatus: "connected", - desktop: true, desktopApps: ["terminal"], }, }, ], }, - "worker.desktop.observe": { + "desktop.observe": { transport: "rfb", wsPath: "/desktop/observe?token=view", expiresAtMs: 60_000, diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 0f0d39acff90..795f04a55196 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -1982,23 +1982,28 @@ export const en: TranslationMap = { resize: "Resize desktop panel", dockBottom: "Dock to bottom", dockRight: "Dock to right", - pickerTitle: "Cloud worker desktops", + pickerTitle: "Desktop sources", + thisMachine: "This machine", refresh: "Refresh", refreshing: "Refreshing…", - loading: "Loading worker environments…", - empty: - "No desktop-capable worker environments exist. Enable one with desktop: true in a crabbox cloud-worker profile.", + loading: "Loading desktop sources…", + empty: "No desktop-capable sources are available.", connect: "Connect", connecting: "Connecting to desktop…", takeControl: "Take control", disconnect: "Disconnect", reconnect: "Reconnect", + passwordPrompt: "Enter the VNC password for this machine.", + passwordLabel: "VNC password", + accountPrompt: "Enter a macOS account to authenticate Screen Sharing.", + usernameLabel: "macOS username", + accountPasswordLabel: "macOS password", controlTaken: "Another operator took control", disconnected: "Desktop disconnected: {reason}", closeCode: "connection closed with code {code}", unknownReason: "unknown reason", errors: { - listFailed: "Could not load worker environments: {error}", + listFailed: "Could not load desktop sources: {error}", securityFailed: "Desktop security negotiation failed: {reason}", }, }, @@ -2867,6 +2872,11 @@ export const en: TranslationMap = { description: "Record content-free metadata for direct conversations in the audit ledger. Message content is never stored.", }, + hostDesktop: { + title: "Host Desktop", + description: + "Watch and control this Gateway machine from the Desktop panel through its existing VNC or Screen Sharing server.", + }, workerDesktop: { title: "Cloud Worker Desktop", description: diff --git a/ui/src/pages/chat/chat-pane-header.ts b/ui/src/pages/chat/chat-pane-header.ts index ef43f5294eb1..c43f13cae835 100644 --- a/ui/src/pages/chat/chat-pane-header.ts +++ b/ui/src/pages/chat/chat-pane-header.ts @@ -180,7 +180,7 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu { session: row, }) : {}; - const desktopPanelAvailable = isDesktopPanelAvailable(this.context.gateway.snapshot, row); + const desktopPanelAvailable = isDesktopPanelAvailable(this.context.gateway.snapshot); const openDesktopPanel = () => window.dispatchEvent( new CustomEvent(DESKTOP_PANEL_TOGGLE_EVENT, { diff --git a/ui/src/pages/chat/chat-pane-terminal.test.ts b/ui/src/pages/chat/chat-pane-terminal.test.ts index 7831a42d2ca7..9c77bc3d7574 100644 --- a/ui/src/pages/chat/chat-pane-terminal.test.ts +++ b/ui/src/pages/chat/chat-pane-terminal.test.ts @@ -68,7 +68,7 @@ describe("chat pane terminal action", () => { } }); - it("renders the desktop controls only for cloud sessions and opens the panel", () => { + it("renders desktop controls for local sessions when the source RPC is available", () => { const client = { request: vi.fn() } as unknown as GatewayBrowserClient; const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); const localSession = { @@ -105,16 +105,16 @@ describe("chat pane terminal action", () => { renderHeader(cloudSession); expect(container.querySelector('[aria-label="Toggle desktop panel"]')).toBeNull(); - snapshot.hello = desktopHello(["worker.desktop.observe"], ["operator.admin"]); + snapshot.hello = desktopHello(["desktop.observe"], ["operator.admin"]); renderHeader(localSession); - expect(container.querySelector('[aria-label="Toggle desktop panel"]')).toBeNull(); - expect(panelActionIds()).not.toContain("desktop"); + expect(container.querySelector('[aria-label="Toggle desktop panel"]')).not.toBeNull(); + expect(panelActionIds()).toContain("desktop"); const events: CustomEvent[] = []; const listener = (event: Event) => events.push(event as CustomEvent); window.addEventListener(DESKTOP_PANEL_TOGGLE_EVENT, listener); try { - renderHeader(cloudSession); + renderHeader(localSession); const button = container.querySelector( '[aria-label="Toggle desktop panel"]', ); @@ -124,7 +124,7 @@ describe("chat pane terminal action", () => { expect(events).toHaveLength(1); expect(events[0]?.detail).toEqual({ open: true }); - snapshot.hello = desktopHello(["worker.desktop.observe"], ["operator.read"]); + snapshot.hello = desktopHello(["desktop.observe"], ["operator.read"]); renderHeader(cloudSession); expect(container.querySelector('[aria-label="Toggle desktop panel"]')).toBeNull(); } finally { diff --git a/ui/src/pages/chat/chat-responsive.browser.test.ts b/ui/src/pages/chat/chat-responsive.browser.test.ts index 7e00a4968cfd..5068ad61dfca 100644 --- a/ui/src/pages/chat/chat-responsive.browser.test.ts +++ b/ui/src/pages/chat/chat-responsive.browser.test.ts @@ -2114,19 +2114,28 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { const punctuationRect = range.getBoundingClientRect(); range.detach(); const chipRect = (node as HTMLElement).getBoundingClientRect(); + const paragraph = (node as HTMLElement).parentElement; + if (!paragraph) { + throw new Error("Expected inline code inside a paragraph"); + } return { horizontalGap: punctuationRect.left - textRect.right, - heightDelta: chipRect.height - punctuationRect.height, + chipHeight: chipRect.height, + lineHeight: Number.parseFloat(getComputedStyle(paragraph).lineHeight), }; }), ); expect(spacing).toHaveLength(2); - for (const { horizontalGap, heightDelta } of spacing) { - // Include the chip border/inset, but keep both measurements within a - // quarter of the 14px prose size across browser font metrics. + for (const { horizontalGap, chipHeight, lineHeight } of spacing) { + // The gap is the chip's em-derived inset plus its border, so a quarter of + // the 14px prose size holds on every platform. expect(horizontalGap).toBeLessThanOrEqual(3.75); - expect(heightDelta).toBeLessThanOrEqual(3.75); + // Measure the chip against the paragraph's CSS line box rather than a text + // rect: the chip's content height follows the monospace font's default line + // spacing, which differs by several px between macOS and Linux. + expect(lineHeight).toBeGreaterThan(0); + expect(chipHeight).toBeLessThanOrEqual(lineHeight + 1); } } finally { await closeBrowserPage(page); diff --git a/ui/src/pages/cron/cron-page.test.ts b/ui/src/pages/cron/cron-page.test.ts index 41eb1d7131fb..3298e1380161 100644 --- a/ui/src/pages/cron/cron-page.test.ts +++ b/ui/src/pages/cron/cron-page.test.ts @@ -4,12 +4,9 @@ import { createDeferred } from "../../../../test/helpers/promise.js"; import type { GatewayBrowserClient, GatewayEventListener } from "../../api/gateway.ts"; import type { CronJob, CronJobsListResult } from "../../api/types.ts"; import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts"; -import { showConfirmDialog } from "../../components/confirm-dialog.ts"; import type { CronState } from "../../lib/cron/index.ts"; import "./cron-page.ts"; -vi.mock("../../components/confirm-dialog.ts", () => ({ showConfirmDialog: vi.fn() })); - type CronTestPage = HTMLElement & { context: ApplicationContext; updateComplete: Promise; @@ -161,7 +158,6 @@ function createRequest() { afterEach(() => { document.body.replaceChildren(); - vi.mocked(showConfirmDialog).mockReset(); vi.restoreAllMocks(); }); @@ -400,6 +396,7 @@ describe("CronPage editor state sync", () => { }; let serverEnabled = true; let removed = false; + const removeRequested = createDeferred(); const request = vi.fn(async (method: string, params?: unknown) => { if (method === "cron.list") { return cronListResponse(removed ? [] : [{ ...job, enabled: serverEnabled }]); @@ -413,6 +410,7 @@ describe("CronPage editor state sync", () => { } if (method === "cron.remove") { removed = true; + removeRequested.resolve(); return {}; } if (method === "cron.runs") { @@ -444,11 +442,19 @@ describe("CronPage editor state sync", () => { await waitForCronPage(() => expect(page.cron.cronForm.enabled).toBe(false)); expect(serverEnabled).toBe(false); - const removeButton = Array.from(page.querySelectorAll(".cron-job-menu__item")).find( - (item) => item.textContent?.trim() === "Remove", - ) as HTMLButtonElement; - vi.mocked(showConfirmDialog).mockResolvedValueOnce(true); - removeButton.click(); + const findRemoveButton = () => + Array.from(page.querySelectorAll(".cron-job-menu__item")).find( + (item) => item.textContent?.trim() === "Remove", + ); + await waitForCronPage(() => expect(findRemoveButton()?.disabled).toBe(false)); + findRemoveButton()?.click(); + const findConfirmButton = () => + Array.from(document.querySelectorAll(".exec-approval-actions .btn")).find( + (button) => button.textContent?.trim() === "Remove", + ); + await waitForCronPage(() => expect(findConfirmButton()).toBeDefined()); + findConfirmButton()?.click(); + await removeRequested.promise; await waitForCronPage(() => expect(page.cron.cronEditingJobId).toBeNull()); await waitForCronPage(() => expect(page.cron.cronRunsScope).toBe("all")); }); diff --git a/ui/src/pages/labs/labs-page.test.ts b/ui/src/pages/labs/labs-page.test.ts index b0cdf324831a..0f18f9dd93d6 100644 --- a/ui/src/pages/labs/labs-page.test.ts +++ b/ui/src/pages/labs/labs-page.test.ts @@ -112,6 +112,7 @@ describe("LabsPage", () => { expect(page.querySelectorAll(".settings-row")).toHaveLength(LAB_FEATURES.length); expect(page.textContent).toContain("Code Mode"); expect(page.textContent).toContain("Swarm"); + expect(page.textContent).toContain("Host Desktop"); expect(page.textContent).toContain("Cloud Worker Desktop"); expect(codeModeToggle(page).checked).toBe(true); @@ -200,6 +201,12 @@ describe("LabsPage", () => { expectedPatch: { logging: { audit: { messages: "direct" } } }, note: "labs: update auditMessages", }, + { + label: "Host Desktop", + sourceConfig: { desktop: { host: { enabled: false } } }, + expectedPatch: { desktop: { host: { enabled: true } } }, + note: "labs: update hostDesktop", + }, { label: "Cloud Worker Desktop", sourceConfig: { cloudWorkers: { desktop: false } }, @@ -257,10 +264,11 @@ describe("LabsPage", () => { const rows = [...page.querySelectorAll(".settings-row")]; const restartRows = rows.filter((row) => row.textContent?.includes("restart")); - expect(restartRows).toHaveLength(2); + expect(restartRows).toHaveLength(3); expect(restartRows.map((row) => row.textContent)).toEqual( expect.arrayContaining([ expect.stringContaining("Message audit metadata"), + expect.stringContaining("Host Desktop"), expect.stringContaining("Cloud Worker Desktop"), ]), ); diff --git a/ui/src/pages/labs/labs-registry.ts b/ui/src/pages/labs/labs-registry.ts index 1944debce230..f8bc552d3cc5 100644 --- a/ui/src/pages/labs/labs-registry.ts +++ b/ui/src/pages/labs/labs-registry.ts @@ -194,6 +194,21 @@ export const LAB_FEATURES = [ // the recorder, so this outlives the reload plan's `logging: none` rule. restartHint: () => t("labsPage.restartRequired"), }, + { + id: "hostDesktop", + title: () => t("labsPage.hostDesktop.title"), + description: () => t("labsPage.hostDesktop.description"), + docsUrl: "https://docs.openclaw.ai/gateway/configuration-reference#desktop", + configPath: ["desktop", "host", "enabled"], + onValue: true, + offValue: false, + activeValues: [true], + readEnabled: null, + enableAlso: null, + resetScope: "gate", + // Method advertisement is resolved at Gateway startup, so the panel appears after restart. + restartHint: () => t("labsPage.restartRequired"), + }, { id: "workerDesktop", title: () => t("labsPage.workerDesktop.title"),