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}