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"),