From 6aefb86ea8bab0d51ef743c43ed168c2126089f2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 00:16:32 -0700 Subject: [PATCH] feat(gateway): view macOS Screen Sharing from the Desktop panel Modern macOS only offers ARD account authentication for Screen Sharing, so the host desktop source refused every Mac. The Gateway now performs the ARD handshake itself against the loopback server and hands the browser a plain RFB 003.008 no-auth handshake, so the operator's macOS account password authenticates the desktop without ever reaching the browser, the observe result, a URL, or a log. - rfb-preauth: ARD (type 30) Diffie-Hellman with MD5-derived AES-128-ECB credentials, and VncAuth (type 2) bit-reversed DES, both under a single 10s negotiation deadline; Apple's RFB 003.889 maps to 3.8 - observe-bridge: runs pre-auth before splicing and starts the view-only filter at clientInit, since the browser handshake is consumed here; worker tokens keep the original version start phase - host-source: attaches ARD, requiring per-observation credentials that live only in the one-shot observer token and are dropped after use - doctor: offers an explicitly confirmed sudo launchctl repair when Screen Sharing is off, and prints the System Settings path otherwise Live-verified against this Mac's Screen Sharing: the DH exchange and credential framing are accepted and the server returns SecurityResult. The VncAuth DES vector is confirmed against OpenSSL independently. --- .../agent-harness-runtime.json | 2 +- .../agent-harness.json | 2 +- .../plugin-sdk-api-baseline/channel-core.json | 2 +- .../channel-entry-contract.json | 2 +- .../channel-message.json | 2 +- .../channel-outbound.json | 2 +- .../channel-plugin-common.json | 2 +- .../plugin-sdk-api-baseline/core.json | 2 +- .../plugin-sdk-api-baseline/discord.json | 2 +- .../gateway-runtime.json | 2 +- .../inbound-reply-dispatch.json | 2 +- .../meeting-runtime.json | 2 +- .../plugin-sdk-api-baseline/plugin-entry.json | 2 +- .../plugin-runtime.json | 2 +- .../provider-catalog-runtime.json | 2 +- .../plugin-sdk-api-baseline/tool-plugin.json | 2 +- .../webhook-ingress.json | 2 +- .../src/schema/desktop.test.ts | 18 + .../gateway-protocol/src/schema/desktop.ts | 19 +- src/commands/doctor-host-desktop.test.ts | 93 ++++ src/commands/doctor-host-desktop.ts | 71 ++- src/config/types.desktop.ts | 2 +- ...tor-health-contribution-runners.gateway.ts | 2 +- src/gateway/desktop/host-guidance.ts | 2 +- .../desktop/host-observe.integration.test.ts | 151 +++++-- src/gateway/desktop/host-source-errors.ts | 15 + src/gateway/desktop/host-source.test.ts | 51 ++- src/gateway/desktop/host-source.ts | 59 ++- src/gateway/desktop/observe-bridge.ts | 206 +++++++-- src/gateway/desktop/rfb-preauth.test.ts | 295 ++++++++++++ src/gateway/desktop/rfb-preauth.ts | 424 ++++++++++++++++++ .../desktop/rfb-view-only-filter.test.ts | 10 + src/gateway/desktop/rfb-view-only-filter.ts | 6 +- src/gateway/desktop/session-registry.ts | 1 + .../environments.desktop.test.ts | 46 ++ src/gateway/server-methods/environments.ts | 21 +- .../desktop/desktop-panel-credentials.ts | 18 + .../desktop/desktop-panel-styles.ts | 164 +++++++ ui/src/components/desktop/desktop-panel.ts | 266 ++++------- ui/src/e2e/desktop-panel.e2e.test.ts | 64 ++- ui/src/i18n/locales/en.ts | 3 + 41 files changed, 1734 insertions(+), 307 deletions(-) create mode 100644 src/gateway/desktop/host-source-errors.ts create mode 100644 src/gateway/desktop/rfb-preauth.test.ts create mode 100644 src/gateway/desktop/rfb-preauth.ts create mode 100644 ui/src/components/desktop/desktop-panel-credentials.ts create mode 100644 ui/src/components/desktop/desktop-panel-styles.ts diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index fd14678f4199..f60b4263ebd5 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":"e9f03948a5f27c201bbb9c985d5246766de68d058960c5a6f9894d5e7c4c5e5f","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"e228330edab112eacfa24979ad599be6cb051ffbf4bc1ac195b4c785e8582b82","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 17c96ebdcf94..4794a8499900 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"3b555f468a1f5285f013d7259ebedd19269e93b3419fff98a46fb39cc7e33260","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"d7a5167cc11614d8a68f9a5d8b17c3e53c8689c8047d351618efaa33c376c754","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 63b3a1e02fcb..fb839ccd20c0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"fa8349747f4225a0ebc001b29ea83c2b4ccef461f4d33064f4a627ce5526187f","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"a3d3f96cf919ed8712cc17b8a2bd3789c091c461994ed6c5f4371e2e8c8aa279","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 9474acf0fa1f..ad50c84da9e9 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":"a2f5849ea6b065cf9330bc23c8d5dde3dc2c887fd4d6ddbc923d3e9c6ced82fb","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"88f2302abb102413a5cd2edcdc571e2420ba56ce0f2278e4b4f43cf4680abb4e","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 0f5a0637308c..cc9573483ffc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"161dd85ff2fb55f99cd244f0536fff9f98f61793456ff94816ea5b4d9ca3f111","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"735c8459a489a5e275924900feacdf70f4090e9d1ef61a60bcb2a55c72712d16","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 0f70056ef465..b09458f830af 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"2f3449d689578379c2b49f52f1d1f0fd38a140f80352f964ba03780aff0b2a20","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"5fda9177a63cda38e65524938d2508e94ebc7ab0a903226153236a691bf72469","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 85229f157943..34a22ca18a8a 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":"00c8d0e72e4bfcb3f265a92049853dcd8b005fdaca827ce25b72fac45117b8f1","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"a7cb3d405ada2310b747b43dddd33ea35b76e0d5f9f6d002d9224910a6dd193f","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index d8a88579f984..c9daafb9e67e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"eef0a7bd90bf5c7d351345a65d78e3d91751da9d61db276cad74366981b5266f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"1024c9cb87bfb6af8eb3d2c349f3357bdc8b8be1746d7b77a708ed468f8914bb","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index 7016ada7f1b3..14e3c5ad070c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"0d2511cd15d9760cd1d5f5e4ea6c2837b4614376705f7c0b0646ea451c56ed30","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"5b949452938af1bfea9a1d2660359936d86e33ceb8429455bcf1ed4d98112cea","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index 6ad9e30620ae..5ae778643bea 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"bcd3c477aa058ce1114a212a6b2dbc1a4040691c68756045c1683eb51a689756","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"1d6787b9e0898a1aa0f9ca690fc1c9af17050fd60cf846c7b864cf3fe1d9d367","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 09c13359398a..86c04929a31f 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":"7a4049e6d740586be9789354dd4a00ea9eb687651a02f29705a0d982ad8f12a7","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"f379d6995a2ee77106b51703e1749aa351fad87e7a3d5696d3ee513f79f97fde","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index ea8e74e7e172..968474bbcb55 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"f2fdfad058605f29a610b906c1658a28bf5ef1ad3a99bd5781e841ed1d8f09dc","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"ecd4857c6cab39ed452b4e7fa2ba613a68ed8f4587ae2cfccac073cd5a6e97f6","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index e48e52c17405..3d9a613d0e7d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"065757589694d6926e52731cf318bef7226583b7afbeddf16726bfdb567c5931","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"bc2670198acb21829e7a3a86c745c415debfd741cde3df6a3ea57fbe422708ce","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 eeb3056d2fbd..1b684160003f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"ba13ff7c2e58e7491f27456bcd53ba5a580a4457f85d668853d5c672419dba14","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"e558cabf4ee7203b829dab7010ed0b899f2a57421ab922a3186fb86c3c55c971","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 1cd4395c0fb7..66ef8127bd57 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":"9e184cbe1f9066c86e59ce18c1c96578d2e4f854c56ab751348fe4e75969af05","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"30bbb4b6dfb48348c2be4866e1d8d4fd52c3afe478d0d88641023afb996439c4","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index 8cfd8cbe42f1..549e3bce96dc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"2ec29056ed15690afefbb066c21fcc131f59ed955136ac608a93e0ed7bab5aeb","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"4cd594761004e8710a26ce22655e3fe79230be17cac189fe3c2bda550ffcc68d","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 cd29f975dec1..08a0174b0f5e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"50cfdf3fb473c5cfea47cf679cdd9ab65d5bba0a5a63bcdda0703fbd5573c343","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"d796b4381271aa5957da466432921010aa922ac354055b13bcfcfbb9dbc1ca59","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/packages/gateway-protocol/src/schema/desktop.test.ts b/packages/gateway-protocol/src/schema/desktop.test.ts index 46e8f12aa8a2..cc4d7de55c84 100644 --- a/packages/gateway-protocol/src/schema/desktop.test.ts +++ b/packages/gateway-protocol/src/schema/desktop.test.ts @@ -9,12 +9,30 @@ import { 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, ); diff --git a/packages/gateway-protocol/src/schema/desktop.ts b/packages/gateway-protocol/src/schema/desktop.ts index e5e5b98b0a5e..593b85715d4c 100644 --- a/packages/gateway-protocol/src/schema/desktop.ts +++ b/packages/gateway-protocol/src/schema/desktop.ts @@ -10,11 +10,24 @@ export const DesktopSourceSchema = Type.Union([ closedObject({ kind: Type.Literal("environment"), environmentId: NonEmptyString }), ]); -export const DesktopObserveParamsSchema = closedObject({ - source: DesktopSourceSchema, - control: Type.Optional(Type.Boolean()), +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, diff --git a/src/commands/doctor-host-desktop.test.ts b/src/commands/doctor-host-desktop.test.ts index 05317ae3a555..e2614580bea0 100644 --- a/src/commands/doctor-host-desktop.test.ts +++ b/src/commands/doctor-host-desktop.test.ts @@ -1,6 +1,7 @@ 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() })); @@ -9,9 +10,28 @@ 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({}); @@ -53,4 +73,77 @@ describe("host desktop doctor section", () => { "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?", + 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 index 7402fb214072..3d668862a521 100644 --- a/src/commands/doctor-host-desktop.ts +++ b/src/commands/doctor-host-desktop.ts @@ -2,6 +2,13 @@ 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( @@ -18,10 +25,64 @@ export async function collectHostDesktopHealthFindings( ]; } -/** Renders the host desktop probe as its own doctor section; no repair is attempted. */ -export async function noteHostDesktopHealth(cfg: OpenClawConfig): Promise { - const [finding] = await collectHostDesktopHealthFindings(cfg); - if (finding) { - note(finding.message, "Host desktop"); +/** 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; + } + const approved = await deps.prompter.confirmRuntimeRepair({ + message: "Enable macOS Screen Sharing now using sudo launchctl?", + 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/config/types.desktop.ts b/src/config/types.desktop.ts index a6b7e6f99225..ec211ef592a0 100644 --- a/src/config/types.desktop.ts +++ b/src/config/types.desktop.ts @@ -5,7 +5,7 @@ export type DesktopHostConfig = { enabled: boolean; /** Loopback RFB port of an already-running VNC server (default: 5900). */ port?: number; - /** Absolute VNC password-file path; omit on macOS for future account/ARD auth support. */ + /** Absolute VNC password-file path; macOS ARD account credentials stay per-observation. */ passwordFile?: string; }; diff --git a/src/flows/doctor-health-contribution-runners.gateway.ts b/src/flows/doctor-health-contribution-runners.gateway.ts index 76dbf356ccf7..c41175be1a3e 100644 --- a/src/flows/doctor-health-contribution-runners.gateway.ts +++ b/src/flows/doctor-health-contribution-runners.gateway.ts @@ -58,7 +58,7 @@ export async function runGatewayServicesHealth(ctx: DoctorHealthFlowContext): Pr export async function runHostDesktopHealth(ctx: DoctorHealthFlowContext): Promise { const { noteHostDesktopHealth } = await import("../commands/doctor-host-desktop.js"); - await noteHostDesktopHealth(ctx.cfg); + await noteHostDesktopHealth(ctx.cfg, { prompter: ctx.prompter }); } export async function runStartupChannelMaintenanceHealth( diff --git a/src/gateway/desktop/host-guidance.ts b/src/gateway/desktop/host-guidance.ts index c607be9c3a5f..a37713f4669b 100644 --- a/src/gateway/desktop/host-guidance.ts +++ b/src/gateway/desktop/host-guidance.ts @@ -1,7 +1,7 @@ /** 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`. Built-in Screen Sharing uses ARD auth, which is not supported yet; use a VncAuth server for this milestone.", + "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: diff --git a/src/gateway/desktop/host-observe.integration.test.ts b/src/gateway/desktop/host-observe.integration.test.ts index 8ef33bd2d59d..227dc4636c8d 100644 --- a/src/gateway/desktop/host-observe.integration.test.ts +++ b/src/gateway/desktop/host-observe.integration.test.ts @@ -1,40 +1,122 @@ import http from "node:http"; import net from "node:net"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { WebSocket } from "ws"; +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())); }); -function readSocketBytes(socket: net.Socket, length: number): Promise { - return new Promise((resolve) => { - let buffered = Buffer.alloc(0); - const onData = (chunk: Buffer) => { - buffered = Buffer.concat([buffered, chunk]); - if (buffered.length >= length) { - socket.off("data", onData); - resolve(buffered); +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(); } - }; - socket.on("data", onData); - }); + 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("upgrades a host token, proxies bytes, and filters view-only input", async () => { - const peers: net.Socket[] = []; + 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.push(socket); - socket.write(Buffer.from("RFB 003.008\n", "ascii")); - if (peers.length === 1) { - socket.once("data", () => socket.write(Buffer.from([1, 2]))); - } + 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); @@ -60,8 +142,11 @@ describe("gateway host desktop observe integration", () => { registry, }); cleanups.push(async () => registry.stopAll()); - const observed = await service.observe(false); - expect(observed.auth).toBe("vnc-password"); + 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(); @@ -83,23 +168,23 @@ describe("gateway host desktop observe integration", () => { ); const ws = new WebSocket(`ws://127.0.0.1:${httpAddress.port}${observed.wsPath}`); + const browser = new WebSocketReader(ws); cleanups.push(async () => ws.terminate()); - const banner = new Promise((resolve, reject) => { - ws.once("message", (data) => resolve(Buffer.from(data as Buffer))); + await new Promise((resolve, reject) => { + ws.once("open", resolve); ws.once("error", reject); }); - await expect(banner).resolves.toEqual(Buffer.from("RFB 003.008\n", "ascii")); - await vi.waitFor(() => expect(peers).toHaveLength(2)); - const observerPeer = peers[1]; - if (!observerPeer) { - throw new Error("expected observer RFB peer"); - } + 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 handshake = Buffer.concat([Buffer.from("RFB 003.008\n", "ascii"), Buffer.from([1, 1])]); 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]); - const forwarded = readSocketBytes(observerPeer, handshake.length + framebufferRequest.length); - ws.send(Buffer.concat([handshake, keyEvent, framebufferRequest])); - await expect(forwarded).resolves.toEqual(Buffer.concat([handshake, framebufferRequest])); + 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 index 415730810db1..78e6965f5fd0 100644 --- a/src/gateway/desktop/host-source.test.ts +++ b/src/gateway/desktop/host-source.test.ts @@ -4,7 +4,12 @@ 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 { createHostDesktopSource } from "./host-source.js"; +import { + createHostDesktopService, + createHostDesktopSource, + inspectHostDesktop, +} from "./host-source.js"; +import { createDesktopSessionRegistry } from "./session-registry.js"; const cleanups: Array<() => Promise> = []; @@ -81,6 +86,7 @@ describe("gateway host desktop source", () => { }); 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); @@ -91,18 +97,55 @@ describe("gateway host desktop source", () => { 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("refuses ARD account auth with the supported alternative", async () => { + 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()).rejects.toThrow( - "macOS Screen Sharing uses ARD account authentication, which is not supported yet; configure a VncAuth server and desktop.host.passwordFile, then restart the gateway", + 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 () => { diff --git a/src/gateway/desktop/host-source.ts b/src/gateway/desktop/host-source.ts index f414a0c69e44..8209d37b8053 100644 --- a/src/gateway/desktop/host-source.ts +++ b/src/gateway/desktop/host-source.ts @@ -3,6 +3,7 @@ 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"; @@ -12,6 +13,7 @@ const HOST_DESKTOP_PROBE_TIMEOUT_MS = 1_500; export type HostDesktopAcquireResult = { attachment: RfbAttachment; + auth: "vnc-password" | "ard-account"; vncPassword?: string; }; @@ -25,6 +27,7 @@ export type HostDesktopStatus = { export type HostDesktopInspection = { status: HostDesktopStatus; detail: string; + unavailableReason?: "not-listening" | "not-rfb" | "unsupported"; }; function nonRfbError(port: number): string { @@ -72,17 +75,19 @@ export async function inspectHostDesktop(params: { 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") { + if (auth === "vnc-password" || auth === "ard-account") { return { status: { enabled: true, state: "attached", port, security }, detail: `attached (127.0.0.1:${port}, security: ${security})`, @@ -91,12 +96,11 @@ export async function inspectHostDesktop(params: { const detail = auth === "none" ? `unavailable: unauthenticated VNC server at 127.0.0.1:${port}; require a password-protected VncAuth server, then retry` - : auth === "ard-account" - ? "unavailable: macOS Screen Sharing uses ARD account authentication, which is not supported yet; configure a VncAuth server and desktop.host.passwordFile, then restart the gateway" - : `unavailable: ${security} security is not supported; configure a VncAuth server and desktop.host.passwordFile, 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", }; } @@ -126,11 +130,6 @@ export function createHostDesktopSource(params: { `refusing unauthenticated VNC server on 127.0.0.1:${port}; require a password-protected VncAuth server, then retry`, ); } - if (security === "ard-account") { - throw new Error( - "macOS Screen Sharing uses ARD account authentication, which is not supported yet; configure a VncAuth server and desktop.host.passwordFile, then restart the gateway", - ); - } if (security === "unsupported") { const name = probe.securityTypes.includes(19) ? "VeNCrypt" : "the offered VNC security"; throw new Error( @@ -161,6 +160,7 @@ export function createHostDesktopSource(params: { } return { attachment: { kind: "tcp", host: "127.0.0.1", port }, + auth: security, ...(vncPassword ? { vncPassword } : {}), }; }; @@ -169,12 +169,15 @@ export function createHostDesktopSource(params: { } export type HostDesktopService = { - observe(control: boolean): Promise<{ + observe(params: { + control: boolean; + credentials?: { username?: string; password?: string }; + }): Promise<{ transport: "rfb"; wsPath: string; expiresAtMs: number; control: boolean; - auth: "vnc-password"; + auth: "vnc-password" | "ard-account"; vncPassword?: string; }>; status(): Promise; @@ -191,27 +194,47 @@ export function createHostDesktopService(params: { ...(params.platform ? { platform: params.platform } : {}), }); return { - async observe(control) { + 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, + control: observeParams.control, attachment: acquired.attachment, + ...(preauth ? { preauth } : {}), }); return { transport: "rfb", wsPath: `/desktop/observe?token=${minted.token}`, expiresAtMs: minted.expiresAtMs, - control, - // Host attach only reaches this point for VncAuth servers; every other - // security type is refused in acquire(). ARD lands in a later milestone. - auth: "vnc-password", - ...(acquired.vncPassword ? { vncPassword: acquired.vncPassword } : {}), + control: observeParams.control, + auth, + ...(auth === "vnc-password" && acquired.vncPassword + ? { vncPassword: acquired.vncPassword } + : {}), }; }, async status() { diff --git a/src/gateway/desktop/observe-bridge.ts b/src/gateway/desktop/observe-bridge.ts index 5d65c9e676a7..932a34391c81 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,7 +25,9 @@ type DesktopObserverTokenEntry = { ownerEpoch: number; control: boolean; attachment: RfbAttachment; + preauth?: RfbPreauthDescriptor; expiresAt: number; + expiryTimer: ReturnType; }; const observerTokens = new Map(); @@ -28,6 +37,7 @@ function pruneDesktopObserverTokens(nowMs: number): void { for (const [token, entry] of observerTokens) { if (entry.expiresAt <= nowMs) { observerTokens.delete(token); + clearTimeout(entry.expiryTimer); } } } @@ -37,19 +47,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, - }); + expiryTimer: setTimeout(() => { + if (observerTokens.get(token) === entry) { + observerTokens.delete(token); + } + }, TOKEN_TTL_MS), + }; + entry.expiryTimer.unref?.(); + observerTokens.set(token, entry); return { token, expiresAtMs }; } @@ -67,6 +86,7 @@ function consumeDesktopObserverToken( return undefined; } observerTokens.delete(normalized); + clearTimeout(entry.expiryTimer); return entry.expiresAt > nowMs ? entry : undefined; } @@ -85,6 +105,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 +197,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 +215,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-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/server-methods/environments.desktop.test.ts b/src/gateway/server-methods/environments.desktop.test.ts index f2138510fe8e..18f61b9491e9 100644 --- a/src/gateway/server-methods/environments.desktop.test.ts +++ b/src/gateway/server-methods/environments.desktop.test.ts @@ -1,6 +1,7 @@ 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"; @@ -110,6 +111,51 @@ describe("desktop gateway methods", () => { 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", diff --git a/src/gateway/server-methods/environments.ts b/src/gateway/server-methods/environments.ts index 45f8693b4025..09c82840d4c4 100644 --- a/src/gateway/server-methods/environments.ts +++ b/src/gateway/server-methods/environments.ts @@ -16,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"; @@ -187,10 +188,28 @@ async function respondDesktopObserve(params: { try { params.respond( true, - await params.context.hostDesktopService.observe(params.request.control ?? false), + await params.context.hostDesktopService.observe({ + control: params.request.control ?? false, + ...("credentials" in params.request && params.request.credentials + ? { credentials: params.request.credentials } + : {}), + }), undefined, ); } catch (error) { + if (isHostDesktopCredentialsRequiredError(error)) { + params.respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, error.message, { + details: { + code: error.detailCode, + auth: error.auth, + }, + }), + ); + return; + } params.respond( false, undefined, 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..f539d73680ee --- /dev/null +++ b/ui/src/components/desktop/desktop-panel-credentials.ts @@ -0,0 +1,18 @@ +const DESKTOP_CREDENTIALS_REQUIRED_CODE = "DESKTOP_CREDENTIALS_REQUIRED"; + +/** Reads the host-observe retry contract without exposing credential material. */ +export function desktopCredentialRequirement(error: unknown): "ard-account" | null { + if (!error || typeof error !== "object" || !("details" in error)) { + return null; + } + const details = error.details; + if (!details || typeof details !== "object") { + return null; + } + return "code" in details && + details.code === DESKTOP_CREDENTIALS_REQUIRED_CODE && + "auth" in details && + details.auth === "ard-account" + ? "ard-account" + : 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..3627958fcda4 --- /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: 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-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 9545ee1d07e2..1c60f1a09688 100644 --- a/ui/src/components/desktop/desktop-panel.ts +++ b/ui/src/components/desktop/desktop-panel.ts @@ -6,7 +6,7 @@ import type { WorkerDesktopAppId, WorkerDesktopLaunchResult, } 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"; @@ -21,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``; @@ -36,15 +38,16 @@ const panelLayout = createDockPanelLayout({ defaultHeight: 420, defaultWidth: 560, }); - type DesktopPanelState = "picker" | "credentials" | "connecting" | "connected" | "disconnected"; type DesktopAppId = WorkerDesktopAppId; type DesktopCredentials = { username?: string; password?: string }; type PendingDesktopConnection = { environmentId: string; - observed: DesktopObserveResult; + control: boolean; + observed?: DesktopObserveResult; operationId: number; }; +type ObservedDesktopConnection = PendingDesktopConnection & { observed: DesktopObserveResult }; function desktopSourceForEnvironment(environment: Pick): DesktopSource { return environment.id === "gateway" @@ -76,6 +79,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement { 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; @@ -87,174 +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-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); - } - `, - ]; + static override styles = [dockPanelStyles, desktopPanelLauncherStyles, desktopPanelStyles]; override connectedCallback(): void { super.connectedCallback(); @@ -330,6 +167,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement { this.environmentId = null; this.source = null; this.credentials = undefined; + this.credentialAuth = undefined; this.desktopApps = []; this.controlling = false; this.disconnectedReason = null; @@ -386,6 +224,7 @@ 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 ?? []), @@ -411,6 +250,12 @@ class OpenClawDesktopPanel extends OpenClawLitElement { const observed = await client.request("desktop.observe", { source, control, + ...(source.kind === "host" && + this.credentialAuth === "ard-account" && + this.credentials?.username && + this.credentials.password + ? { credentials: this.credentials } + : {}), }); if (operationId !== this.operationId) { return; @@ -421,18 +266,32 @@ class OpenClawDesktopPanel extends OpenClawLitElement { ? this.credentials : undefined; if (observed.auth === "vnc-password" && !credentials?.password) { - this.pendingConnection = { environmentId, observed, operationId }; + this.credentialAuth = "vnc-password"; + this.pendingConnection = { environmentId, control, observed, operationId }; this.state = "credentials"; return; } - await this.connectObserved({ environmentId, observed, operationId }, credentials); + 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: PendingDesktopConnection, + pending: ObservedDesktopConnection, credentials?: DesktopCredentials, ): Promise { const client = this.client; @@ -498,19 +357,49 @@ class OpenClawDesktopPanel extends OpenClawLitElement { if (!pending || pending.operationId !== this.operationId) { return; } - const password = new FormData(event.currentTarget as HTMLFormElement).get("password"); + const formData = new FormData(event.currentTarget as HTMLFormElement); + const password = formData.get("password"); if (typeof password !== "string" || password.length === 0) { return; } - const credentials = { password }; + 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; - void this.connectObserved(pending, credentials); + 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" && @@ -749,12 +638,29 @@ class OpenClawDesktopPanel extends OpenClawLitElement { } private renderCredentials() { + const ardAccount = this.credentialAuth === "ard-account"; return html`
-
-
${t("desktop.passwordPrompt")}
+ this.handleCredentialsSubmit(event)} + > +
${t(ardAccount ? "desktop.accountPrompt" : "desktop.passwordPrompt")}
+ ${ardAccount + ? html`` + : nothing}